2024. 10. 10. 17:30ㆍSpring
저번 메모장 기능 구현에 이어 이번에는 실시간 채팅을 한 번 만들어 볼 것이다.
실전에 들어가기 앞서, 이번 채팅 구현에 있어서는 stomp에 대해서 알고 이 녀석을 사용해 줄거다.
STOMP
STOMP란, Simple Text Oriented Messaging Protocol의 약자이다.
간단한 메세지를 전송하기 위한 프로토콜로 메세지 브로커를 publisher - subscriber 방식을 사용한다.
메세지의 발행자와 구독자가 존재하고 메세지를 보내는 사람과 받는 사람이 구분되어 있다.
메세지 브로커는 발행자가 보낸 메세지를 구독자에게 전달해주는 역할을 한다.
STOMP는 HTTP와 비슷하게frame 기반 프로토콜 command, header, body로 이루어져 있다.
사전 설정
servlet-context.xml
prefix 지정
(web socket message broker prefix / web socket simple broker prefix)
<!-- Resolves views selected for rendering by @Controllers to .jsp resources in the /WEB-INF/views directory -->
<beans:bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<beans:property name="prefix" value="/WEB-INF/views/" />
<beans:property name="suffix" value=".jsp" />
</beans:bean>
<view-controller path="/" view-name="home" />
<context:component-scan base-package="com.itbank.controller" />
<websocket:message-broker application-destination-prefix="/app">
<websocket:stomp-endpoint path="/endpoint">
<websocket:sockjs websocket-enabled="true" />
</websocket:stomp-endpoint>
<websocket:simple-broker prefix="/broker" />
</websocket:message-broker>
</beans:beans>
root-context.xml
스프링빈 등록을 위한 scan 지정
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd">
<!-- Root Context: defines shared resources visible to all other web components -->
<context:component-scan base-package="com.itbank.repository" />
</beans>
pom.xml
<!-- 스프링에서 웹소켓을 처리할 수 있도록 하는 라이브러리 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
<version>${org.springframework-version}</version>
</dependency>
<!-- 스프링에서 STOMP 처리를 위한 라이브러리 -->
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-stomp</artifactId>
<version>5.4.13</version>
</dependency>
</dependencies>
ChatController
방 목록 보여주기, 개설된 채팅방으로 입장하기
import java.util.List;
import javax.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.itbank.model.RoomDTO;
import com.itbank.repository.ChatRoomRepository;
@Controller
@RequestMapping("/chat")
public class ChatController {
@Autowired
private ChatRoomRepository repository;
@GetMapping("/rooms")
public ModelAndView rooms(String username, HttpSession session) {
ModelAndView mav = new ModelAndView();
if(username != null) {
session.setAttribute("username", username);
session.setMaxInactiveInterval(600);
}
List<RoomDTO> list = repository.findAllRooms();
System.out.println("=== 현재 개설된 방 목록 ===");
list.forEach(System.out::println);
System.out.println("========================\n");
mav.addObject("list", list);
return mav;
}
@PostMapping("/rooms")
public String create(String name, RedirectAttributes rttr) {
RoomDTO room = repository.createChatRoom(name);
rttr.addFlashAttribute("roomName", room.getName());
return "redirect:/chat/rooms"; // -> @GetMapping("/rooms")
}
@GetMapping("/room")
public ModelAndView getRoom(String roomId) {
ModelAndView mav = new ModelAndView();
mav.addObject("room", repository.findRoomById(roomId));
return mav;
}
}
ChatRepository
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Repository;
import com.itbank.model.RoomDTO;
@Repository
public class ChatRoomRepository {
private Map<String, RoomDTO> roomMap = new LinkedHashMap<>();
public List<RoomDTO> findAllRooms() { // 모든 방의 객체를 리스트로 반환
List<RoomDTO> result = new ArrayList<>(roomMap.values()); // Map의 values만 추출
Collections.reverse(result); // 순서 뒤집기(최신방먼저)
return result;
}
public RoomDTO findRoomById(String id) { // 저장된 방은 각각 고유 id가 있다
return roomMap.get(id); // id를 key로 사용하여 방을 찾아 반환
}
public RoomDTO createChatRoom(String name) { // 방 생성, 이름을 전달받는다
RoomDTO room = RoomDTO.create(name); // 이름을 전달하여 방 객체 생성
roomMap.put(room.getRoomId(), room); // 방의 id를 key로 지정하여 Map에 저장
return room; // 생성한 방을 반환
}
}
StompController
채팅방 안에서 이루어지는 대화
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import com.itbank.model.MessageDTO;
@Controller
public class StompController {
@MessageMapping("/enter/{roomId}") // 들어오는 주소
@SendTo("/broker/room/{roomId}") // 브로커에게 보내는 주소 (브로커가 다시 클라이언트에게 보낸다)
public MessageDTO enter(MessageDTO message) {
message.setText(message.getFrom() + "님이 채팅방에 참여하였습니다");
message.setFrom("service");
return message;
}
@MessageMapping("/message/{roomId}")
@SendTo("/broker/room/{roomId}")
public MessageDTO message(MessageDTO message) {
return message;
}
}
RoomDTO
생성자를 이용하여 UUID로 방 번호를 가지게 구현
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import org.springframework.web.socket.WebSocketSession;
// 채팅방
public class RoomDTO {
private String roomId;
private String name;
private Set<WebSocketSession> sessions = new HashSet<>();
// 웹소켓세션을 저장, 중복을 허용하지 않는다. for문으로 순회 가능하다
// 자바 빈즈 DTO는 기본생성자만 가지는 편이 좋다
public static RoomDTO create(String name) {
RoomDTO room = new RoomDTO();
room.roomId = UUID.randomUUID().toString().substring(0, 8);
room.name = name;
return room;
}
@Override
public String toString() {
String form = "%s] %s\n%s";
return String.format(form, roomId, name, sessions);
}
public String getRoomId() {
return roomId;
}
public void setRoomId(String roomId) {
this.roomId = roomId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Set<WebSocketSession> getSessions() {
return sessions;
}
public void setSessions(Set<WebSocketSession> sessions) {
this.sessions = sessions;
}
}
MessageDTO
public class MessageDTO {
private String roomId;
private String from;
private String text;
private String time;
public String getRoomId() {
return roomId;
}
public void setRoomId(String roomId) {
this.roomId = roomId;
}
public String getFrom() {
return from;
}
public void setFrom(String from) {
this.from = from;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public String getTime() {
return time;
}
public void setTime(String time) {
this.time = time;
}
}
사용자에게 보여지는 부분
home.jsp
사용자의 이름을 입력하고, 채팅방 목록에 입장.
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<c:set var="cpath" value="${pageContext.request.contextPath }" />
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ws02 - STOMP를 활용한 웹소켓 채팅</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.js"></script>
</head>
<body>
<h1>ws02 - STOMP를 활용한 웹소켓 채팅</h1>
<hr>
<div id="root">
<form action="${cpath }/chat/rooms">
<input name="username" required autofocus>
<input type="submit" value="입장">
</form>
</div>
</body>
</html>
rooms.jsp
개설된 방 목록을 보여주는 페이지.
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<c:set var="cpath" value="${pageContext.request.contextPath }" />
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>방 목록</title>
</head>
<body>
<h1><a href="${cpath }">rooms.jsp - ${username }</a></h1>
<hr>
<fieldset>
<form action="${cpath }/chat/rooms" method="POST">
<input type="text" name="name" placeholder="방제" autocomplete="off" autofocus required>
<input type="submit" value="채팅방 개설">
</form>
</fieldset>
<ul>
<c:forEach var="room" items="${list }">
<li><a href="${cpath }/chat/room?roomId=${room.roomId}">${room.name }</a></li>
</c:forEach>
</ul>
</body>
</html>
room.jsp
각각의 방을 보여주는 페이지
sockJS는 js라이브러리이며, stomp 위에서 돌아가고 있다고 생각하면 된다.
(stomp : 서브 프로토콜)
json.parse : json을 객체로 변환한다.
json.stringify : 객체를 json으로 변환한다.
json.stringify({roomId : roomId, from : username})
> 중괄호 안에 있는 것이 {객체}
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<c:set var="cpath" value="${pageContext.request.contextPath }" />
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>room.jsp - ${room.name }</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.js"></script>
<script>
// 자바스크립트 함수 정의
function onReceive(chat) { // 메시지를 받으면
const content = JSON.parse(chat.body) // JSON을 객체로 변환하고
const from = content.from // 누구에게서 온 메시지인지
const text = content.text // 어떤 내용인지
let str = ''
str += '<div class="' + (from == 'service' ? 'service' : from == username ? 'right' : 'left') + '">'
str += '<div>'
str += '<b>' + (from != 'service' ? from + ': ' : '') + text + '</b>'
str += '<br><sub>' + content.time + '</sub>'
str += '</div></div>'
messageArea.innerHTML += str // 태그로 구성하여 화면에 반영
messageArea.scrollTop = messageArea.scrollHeight // 스크롤 이동시키기
}
function onConnect() {
console.log('STOMP Connection')
stomp.subscribe('/broker/room/' + roomId, onReceive) // 구독할 채널, 메시지 받으면 실행할 함수
stomp.send('/app/enter/' + roomId, {}, JSON.stringify({ // 서버에게 입장 메시지와 시간을 보낸다
roomId: roomId,
from: username,
//time: getCurrentHHmm(),
}))
document.querySelector('input[name="msg"]').focus()
}
function onInput() { // 클라이언트가 메시지를 입력할 때
const text = document.querySelector('input[name="msg"]').value // 내용을 불러와서
if(text == '') { // 내용이 없으면 중단
return
}
document.querySelector('input[name="msg"]').value = '' // 입력창을 비워준다
stomp.send('/app/message/' + roomId, {}, JSON.stringify({
roomId: roomId, // 방번호, 사용자, 내용을 JSON으로 보낸다
from: username,
text: text,
//time: getCurrentHHmm()
}))
document.querySelector('input[name="msg"]').focus() // 다시 입력할 수 있도록 포커스를 잡아준다
}
// JSP에서 자바스크립트로 넘기는 변수
const roomName = '${room.name}'
const roomId = '${room.roomId}'
const username = '${username}'
const cpath = '${cpath}'
</script>
<style>
#messageArea {
border: 2px solid black;
width: 700px;
height: 250px;
margin: 20px 0;
word-wrap: break-word;
overflow-y: scroll;
scroll-behavior: smooth;
}
#messageArea > div > div {
margin: 10px;
padding: 10px 20px;
border: 0.5px solid black;
border-radius: 20px;
width: fit-content;
box-shadow: 2px 2px 2px grey;
}
.service {
display: flex;
justify-content: center;
}
.service > div {
background-color: #f5f6f7;
}
.left {
display: flex;
justify-content: flex-start;
}
.right {
display: flex;
justify-content: flex-end;
}
.right > div {
background-color: yellow;
}
.service sub {
clear: both;
display: none;
}
sub {
color: grey;
}
.left sub {
float: left;
}
.right sub {
float: right;
}
</style>
</head>
<body>
<h1><a href="${cpath }">room.jsp - ${room.name }</a></h1>
<hr>
<div id="messageArea"></div>
<div id="input">
<input type="text" name="msg" id="msg" placeholder="내용을 입력하세요">
<input type="button" value="send">
<a id="disconnect" href="${cpath }/chat/rooms"><button>나가기</button></a>
</div>
<script>
if(roomId == '') {
location.href = cpath
}
const messageArea = document.getElementById('messageArea')
const sockJS = new SockJS(cpath + '/endpoint')
const stomp = Stomp.over(sockJS)
const sendBtn = document.querySelector('input[value="send"]')
const msgInput = document.querySelector('input[name="msg"]')
const leaveLink = document.getElementById('disconnect')
stomp.connect({}, onConnect)
// leaveLink.onclick = onDisconnect
sendBtn.onclick = onInput
msgInput.onkeyup = function(e) {
if(e.key == 'Enter') onInput()
}
</script>
</body>
</html>
대부분이 그렇겠지만 처음부터 코드 하나 하나를 뜯어보기 보다는
먼저 전체적인 흐름을 이해하고 들어가는게 좋다.
방이 어떤식으로 개설 되었고, 그 방을 어떻게 구독하고... 이러한 흐름을 잡고 들어가면 훨씬 이해하기가 쉽다.
'Spring' 카테고리의 다른 글
[Spring] 인증 메일 보내기 (1) | 2024.10.11 |
---|---|
[Project] 실시간 1:1 채팅 (0) | 2024.10.10 |
[Spring] WebSocket을 활용한 메모장 만들기 (2) | 2024.10.10 |
[Spring] HashMap json mapping (2) | 2024.10.10 |
[Spring] 매장 포스기 시스템 (1) | 2024.10.09 |