2023-10-27
1. 주요 빌드 정보
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'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' //순수 HTML 사용 시 불필요
implementation 'org.springframework.boot:spring-boot-starter-freemarker'
implementation 'org.webjars.bower:bootstrap:4.3.1' //순수 HTML 사용 시 불필요
implementation 'org.webjars.bower:axios:0.17.1'
implementation 'com.google.code.gson:gson:2.8.0'
2. Config
WebSocketConfig
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
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
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
/*
* TODO 각 요청에 따라 메시지 정보 및 인증 정보 컨트롤
*
*/
return ChannelInterceptor.super.preSend(message, channel);
}
});
}
}
아래의 내용은 소켓 등록 시 /ws 경로를 사용할 것이라는 의미이며, CORS 모든 경로를 허용하겠다는 의미이다. 구현 과정에서 front를 따로 두는 경우가 많을 텐데, 그러한 케이스에서 필요한 설정이다.
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
메시지를 보내는 경로의 prefix 값은 /app으로 주고 각각의 메시지 세션정보 관리를 하는 브로커의 시작은 /topic으로 하겠다는 의미이다.
즉 각 메세지 세션의 /topic/room/1 | topic/room/2 | topic/room/3... 이런 식으로 관리되게 된다.
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic");
메시지 전송과정에서의 로직 처리 전 해당 메시지 정보를 캐치할 수 있는 메서드이다. TODO는 각자의 요구사항에 맞게 커스텀이 필요하다.
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {...}
WebSocketEventListener
유저가 예상치 못한 동작으로 세션이 없어질 경우 동작한다.(어떤 경우든 세션이 끊어지면 동작한다.) 해당 부분은 구현하지 않아도 동작에는 아무런 이상이 없다.
import com.app.vivada.chat.dto.ChatMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketEventListener {
private final SimpMessageSendingOperations sendingOperations;
/*
* 유저가 채팅방을 나갔을 경우 동작하는 이벤트 핸들러
* @param event
*/
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String username = (String) headerAccessor.getSessionAttributes().get("username");
String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
if (username != null && roomId != null) {
log.info("User disconnected: {}", username + " roomId ->" + roomId);
sendingOperations.convertAndSend("/topic/chat/room/" + roomId,
ChatMessage
.builder()
.sender(username)
.roomId(roomId)
.message(username + "님이 방에서 나가셨습니다.")
.build());
}else{
log.info(" ==== [ERROR] username | roomId is null ");
}
}
}
3. Controller
ChatController
실제 메시징 처리가 필요한 부분의 컨트롤러이다. chatRoomLeave는 세션 기반으로 할지 토큰 기반으로 아직 고민 중이라 TODO로 남겨 두었다. 현재는 메시지 창에서 퇴장 문구만 뜨게 구현이 되어있다.(방에서 나가지지는 않음..)
import com.***.***.***.dto.ChatMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
@RestController
@RequiredArgsConstructor
public class ChatController {
private final SimpMessageSendingOperations sendingOperations;
@MessageMapping("/chat/room/enter")
public void enter(ChatMessage message, SimpMessageHeaderAccessor headerAccessor) {
message.setMessage(message.getSender()+"님이 입장하였습니다.");
headerAccessor.getSessionAttributes().put("username", message.getSender());
headerAccessor.getSessionAttributes().put("roomId", message.getRoomId());
sendingOperations.convertAndSend("/topic/chat/room/"+message.getRoomId(),message);
}
@MessageMapping("/chat/message")
public void chat(ChatMessage message, SimpMessageHeaderAccessor headerAccessor) {
sendingOperations.convertAndSend("/topic/chat/room/"+message.getRoomId(),message);
}
@MessageMapping("chat/room/leave")
public void chatRoomLeave(ChatMessage message, SimpMessageHeaderAccessor headerAccessor) throws IOException {
message.setMessage(message.getSender()+"님이 퇴장하셨습니다.");
// WebSocket 세션을 종료할 세션 아이디 가져오기
String sessionId = headerAccessor.getSessionId();
/*
* TODO 세션을 제거하거나 토큰 기반으로 세션제거 구현 코드 필요
*
*/
sendingOperations.convertAndSend("/topic/chat/room/"+message.getRoomId(),message);
}
}
ChatRoomController
채팅방 생성과 같은 요청을 받는 컨트롤러이다. 일반적인 컨트롤러와 크게 차이가 없다.
import com.***.dto.ChatRoom;
import com.***.service.ChatService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatRoomController {
private final ChatService chatService;
@GetMapping("/room")
public String getRoomList(Model model) {
/*
* TODO 접속 유저에 따라 방 정보를 구분할 필요 있음
* 파라미터로 전달 받은 정보 기준 ex) session | token | 인증객체
*/
return "/chat/room";
}
@GetMapping("/rooms")
@ResponseBody
public List<ChatRoom> getRoomListResponseBody() {
/*
* TODO 접속 유저에 따라 방 정보를 구분할 필요 있음
* 파라미터로 전달 받은 정보 기준 ex) session | token | 인증객체
*/
return chatService.findAllRoom();
}
@PostMapping("/room")
@ResponseBody
public ChatRoom makeRoom(@RequestParam String name) {
/*
* TODO 요청 유저 정보를 별개의 파라미터로 받아 처리해야함
*/
return chatService.makeRoom(name);
}
@GetMapping("/room/enter/{roomId}")
public String enterRoom(Model model, @PathVariable String roomId) {
/*
* TODO 요청 유저 정보를 별개의 파라미터로 받아 처리해야함
* 방에 따른 유저 권한 체크
*/
model.addAttribute("roomId", roomId);
return "/chat/roomdetail";
}
@GetMapping("/room/{roomId}")
@ResponseBody
public ChatRoom roomInfo(@PathVariable String roomId) {
/*
* TODO 요청 유저 정보를 별개의 파라미터로 받아 처리해야함
*/
return chatService.findById(roomId);
}
}
4. Service
ChatService
import java.util.List;
public interface ChatService {
List<ChatRoom> findAllRoom();
ChatRoom makeRoom(String name);
ChatRoom findById(String roomId);
}
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
@Slf4j
public class ChatServiceImpl implements ChatService {
//DB 사용시 제거 가능
private Map<String, ChatRoom> chatRooms;
//DB 사용시 제거 가능
@PostConstruct
private void init() {
chatRooms = new LinkedHashMap<>();
}
@Override
public List<ChatRoom> findAllRoom() {
return new ArrayList<>(chatRooms.values());
}
@Override
public ChatRoom findById(String roomId) {
return chatRooms.get(roomId);
}
@Override
public ChatRoom makeRoom(String name) {
ChatRoom chatRoom = ChatRoom.make(name);
chatRooms.put(chatRoom.getRoomId(), chatRoom);
return chatRoom;
}
}
5. DTO
ChatMessage
import lombok.*;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
public class ChatMessage {
private String roomId;
private String message;
private String sender;
private String time; // 채팅 발송 시간
private MessageType type;
}
ChatRoom
import lombok.*;
import java.util.UUID;
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChatRoom {
private String roomId;
private String roomName;
public static ChatRoom make(String name) {
ChatRoom room = new ChatRoom();
room.roomId = UUID.randomUUID().toString();
room.roomName = name;
return room;
}
}
6. View
방리스트를 구현하는 화면이다.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Websocket Chat</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<!-- CSS -->
<link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div class="container" id="app">
<div class="row">
<div class="col-md-12">
<h3>채팅방 리스트</h3>
</div>
</div>
<div class="input-group">
<div class="input-group-prepend">
<label class="input-group-text">방제목</label>
</div>
<input type="text" class="form-control" id="room_name">
<div class="input-group-append">
<button class="btn btn-primary" type="button" id="createRoom">채팅방 개설</button>
</div>
</div>
<ul class="list-group" id="chatrooms">
</ul>
</div>
<!-- JavaScript -->
<script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
var roomNameInput = document.getElementById("room_name");
var createRoomButton = document.getElementById("createRoom");
var chatroomsList = document.getElementById("chatrooms");
createRoomButton.addEventListener("click", createRoom);
function findAllRoom() {
axios.get('/chat/rooms').then(function(response) {
var chatrooms = response.data;
chatroomsList.innerHTML = ""; // Clear the existing list
chatrooms.forEach(function(item) {
var li = document.createElement("li");
li.className = "list-group-item list-group-item-action";
li.textContent = item.roomName;
li.addEventListener("click", function() {
enterRoom(item.roomId);
});
chatroomsList.appendChild(li);
});
});
}
function createRoom() {
var roomName = roomNameInput.value;
if (roomName === "") {
alert("방 제목은 필수값입니다.");
return;
} else {
var params = new URLSearchParams();
params.append("name", roomName);
axios.post('/chat/room', params)
.then(function(response) {
roomNameInput.value = "";
findAllRoom();
})
.catch(function(response) {
alert("방 생성 실패");
});
}
}
function enterRoom(roomId) {
var sender = prompt('사용자 이름을 입력해주세요');
if (sender !== "") {
localStorage.setItem('wschat.sender', sender);
localStorage.setItem('wschat.roomId', roomId);
location.href = "/chat/room/enter/" + roomId;
}
}
findAllRoom(); // Load chat rooms on page load
});
</script>
</body>
</html>
실제 방에 입장했을때 동작하는 view 화면이다.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Websocket ChatRoom</title>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div class="container" id="app">
<div>
<h2 id="roomName"></h2>
</div>
<div class="input-group">
<div class="input-group-prepend">
<label class="input-group-text">내용</label>
</div>
<input type="text" class="form-control" id="messageInput">
<div class="input-group-append">
<button class="btn btn-primary" type="button" id="sendMessageButton">보내기</button>
<button class="btn btn-danger" type="button" id="leaveRoomButton">나가기</button>
</div>
</div>
<ul class="list-group" id="messagesList">
</ul>
<div></div>
</div>
<!-- JavaScript -->
<script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
<script src="/webjars/sockjs-client/1.1.2/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/2.3.3-1/stomp.min.js"></script>
<script>
var roomId = '';
var sender = '';
document.addEventListener("DOMContentLoaded", function() {
var roomNameElement = document.getElementById("roomName");
var messageInputElement = document.getElementById("messageInput");
var sendMessageButton = document.getElementById("sendMessageButton");
var leaveRoomButton = document.getElementById("leaveRoomButton");
var messagesList = document.getElementById("messagesList");
roomId = localStorage.getItem('wschat.roomId');
sender = localStorage.getItem('wschat.sender');
findRoom();
sendMessageButton.addEventListener("click", sendMessage);
leaveRoomButton.addEventListener("click", liveRoom);
function findRoom() {
axios.get('/chat/room/' + roomId).then(function(response) {
roomNameElement.textContent = response.data.name;
});
}
function sendMessage() {
var message = messageInputElement.value;
ws.send("/app/chat/message", {}, JSON.stringify({ type: 'CHAT', roomId: roomId, sender: sender, message: message }));
messageInputElement.value = '';
}
function liveRoom() {
ws.send("/app/chat/room/leave", {}, JSON.stringify({ type: 'LEAVE', roomId: roomId, sender: sender, message: '' }));
messageInputElement.value = '';
}
function recvMessage(recv) {
var message = document.createElement("li");
message.className = "list-group-item";
message.textContent = recv.sender + " - " + recv.message;
messagesList.insertBefore(message, messagesList.firstChild);
}
function connect() {
// pub/sub event
ws.connect({}, function(frame) {
ws.subscribe("/topic/chat/room/" + roomId, function(message) {
var recv = JSON.parse(message.body);
recvMessage(recv);
});
ws.send("/app/chat/room/enter", {}, JSON.stringify({ type: 'JOIN', roomId: roomId, sender: sender }));
}, function(error) {
alert("채팅방 접속 오류")
console.log(error)
});
}
var sock = new SockJS("/ws");
var ws = Stomp.over(sock);
connect();
});
</script>
</body>
</html>
* 참고 자료
https://velog.io/@rainbowweb/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-STOMP%EB%A1%9C-%EC%B1%84%ED%8C%85%EB%B0%A9-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0
https://hyeooona825.tistory.com/89
https://www.youtube.com/watch?v=TywlS9iAZCM&list=LL&index=16&t=4s&ab_channel=BoualiAli
메인 이미지 출처 : 사진: Unsplash의Mauro Lima