today_is

[ websocket ] 채팅 본문

spring

[ websocket ] 채팅

ye_rang 2024. 3. 4. 10:17

 오늘의 목표 

웹소켓으로 다른 사람과 채팅을 해보자.

 

 


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 

: 방 목록 보여주기 , 개설된 채팅방으로 들어가기

package com.itbank.controller;

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 

package com.itbank.repository;

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 

: 채팅방 안에서 이루어지는 대화

package com.itbank.controller;

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 로 방 번호를 가지게 했음

package com.itbank.model;

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 

package com.itbank.model;

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>

 

 

 study_review 

 

사실 오늘 공부한게 완벽하게 이해가 가지 않아서

집와서 아날로그식으로 글 쓰면서 이해하려고 노력했다 

 

글씨는 영 별로지만

구조 파악하는데에는 직접 글로 써보면서 흐름을 따라가는것이 가장 좋은 방법이라 생각한다

 

 

 

각 함수는 하나의 기능만 처리되도록 만들기

예를 들어, onconnect() 함수는 연결만 되도록 !