WebSocket 포스팅에서 웹소켓이 무엇인지 간략하게 알아봤다.
이번에는 Spring에서 WebSocket을 이용해 실시간 채팅을 만드는 예시를 작성해보려한다.
포스팅의 목적이 WebSocket을 활용한 간단한 예시이기 때문에 단순한 구조로 만들 생각이다.
각 사용자가 입장버튼을 이용해 채팅방에 입장하고, 퇴장버튼을 통해 채팅방에서 나갈 수 있게 만들 예정이다.
사용자는 입장버튼을 클릭하기 이전에 채팅방 내에서 사용할 자신의 이름을 작성해야하며, 채팅방에 입장한 사용자들의 대화는 실시간으로 상대방에게 전해져야하고, 채팅방을 나갔다가 들어오는 경우 이전 메시지가 사라져야한다.
또한, 자신이 보낸 메시지는 입력을 제외하고 출력되지 않게한다 ( 2번 출력 x )
dependencies {
// websocket 의존성
implementation 'org.springframework.boot:spring-boot-starter-websocket'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
Spring Boot에서는 stater 형태로 WebSocket 의존성을 제공해준다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/chat");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chat-sample");
}
}
WebSocket 연결을 위한 설정파일을 작성한다.
먼저, @EnableWebSocketMessageBroker
를 통해 WebSocket 메시지 브로커를 활성화 한다.
registerStompEndpoints
를 통해 STOMP WebSocket 연결을 위한 엔드포인트를 설정한다.
이 경우 /chat-sample
이므로, ws://localhost:8080/chat-sample
가 된다.
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/chat");
registry.setApplicationDestinationPrefixes("/app");
}
위 메서드를 통해 /app
경로로 들어오는 요청들은 컨트롤러로 라우팅하고,
/chat
경로로 시작하는 메시지는 구독한 클라이언트에게 전달한다.
public class Message {
private String name;
private String content;
public Message() {
}
public Message(String name, String content) {
this.name= name;
this.content = content;
}
public String getName() {
return name;
}
public String getContent() {
return content;
}
public void setName(String name) {
this.name = name;
}
public void setContent(String content) {
this.content = content;
}
}
메시지는 간단하게 이름과 내용을 입력받는 형태로 구성했다.
@Controller
public class MessageController {
@MessageMapping("/chat")
@SendTo("/chat/sample")
public Message chatMessage(Message message) throws Exception {
return message;
}
}
STOMP 메시지를 받아들이기 위해 Controller를 작성한다.
$("#sendButton").click(function() {
const chatInput = $("#chatInput").val().trim();
if (chatInput !== "" && stompClient) {
const message = {
name: username,
content: chatInput
};
// 서버로 메시지 전송
stompClient.publish({
destination: "/app/chat",
body: JSON.stringify(message)
});
displayMessage(username, chatInput, true);
$("#chatInput").val("");
}
});
앞선 설정파일을 통해 prefix를 /app
으로 구성했으므로, /app/chat
으로 STOMP 메시지가 도착하면 @MessageMapping("/chat")
이 설정된 메서드로 라우팅된다. 이때, name과 content로 이뤄진 JSON 데이터가 앞서 작성한 Message
클래스로 매핑되어 매개변수로 주어진다.
이렇게 받아들인 메서드는 @SendTo
에 작성된 경로로 값을 반환한다.
해당 경로는 /chat
으로 시작하기 때문에 설정파일에 작성한 enableSimpleBroker("/chat")
에 의해 SimpleBroker를 통해 클라이언트에게 전달된다.
<!-- index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>채팅방</title>
<link rel="stylesheet" href="/styles.css"> <!-- CSS 파일 링크 -->
<script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.0.0/bundles/stomp.umd.min.js"></script>
<script src="/app.js"></script> <!-- JavaScript 파일 링크 -->
</head>
<body>
<div class="container">
<h2>채팅방 입장</h2>
<input type="text" id="username" placeholder="이름을 입력하세요" />
<button id="enterButton">입장하기</button>
<div id="message" class="message"></div>
<div id="chatArea">
<div id="chatWindow"></div>
<input type="text" id="chatInput" placeholder="메시지를 입력하세요" />
<button id="sendButton">전송</button>
<button id="exitButton">퇴장하기</button>
</div>
</div>
</body>
</html>
// app.js
let stompClient = new StompJs.Client({
brokerURL: 'ws://localhost:8080/chat-sample'
});
stompClient.onConnect = (frame) => {
stompClient.subscribe('/chat/sample', function (message) {
const receivedMessage = JSON.parse(message.body);
if(receivedMessage.name !== username){
displayMessage(receivedMessage.name, receivedMessage.content, false);
}
});
};
stompClient.onWebSocketError = (error) => {
console.error('Error with websocket', error);
};
stompClient.onStompError = (frame) => {
console.error('Broker reported error: ' + frame.headers['message']);
console.error('Additional details: ' + frame.body);
};
let username = "";
// WebSocket 연결
function connectWebSocket() {
stompClient.activate();
}
// 메시지 표시
function displayMessage(sender, message, isUser) {
const chatWindow = $("#chatWindow");
const messageRow = $("<div>").addClass("message-row");
const messageBubble = $("<div>").addClass(isUser ? "user-message" : "other-message").text(`${sender}: ${message}`);
messageRow.append(messageBubble);
chatWindow.append(messageRow);
chatWindow.scrollTop(chatWindow[0].scrollHeight);
}
$(function (){
// 채팅방 입장
$("#enterButton").click(() => {
console.log("?");
username = $("#username").val().trim();
if (username === "") {
$("#message").text("이름을 입력해주세요.").css("color", "red");
} else {
$("#enterButton").hide(); // 입장하기 버튼 숨김
$("#username").prop("disabled", true); // 이름 입력칸 비활성화
$("#message").text(`${username}님, 채팅방에 입장하셨습니다.`).css("color", "green");
$("#chatArea").show();
// WebSocket 연결
connectWebSocket();
}
});
// 메시지 전송
$("#sendButton").click(() => {
const chatInput = $("#chatInput").val().trim();
if (chatInput !== "" && stompClient) {
const message = {
name: username,
content: chatInput
};
// 서버로 메시지 전송
stompClient.publish({
destination: "/app/chat",
body: JSON.stringify(message)
});
displayMessage(username, chatInput, true);
$("#chatInput").val("");
}
});
// 채팅방 퇴장
$("#exitButton").click(() => {
stompClient.deactivate();
$("#username").val("");
$("#message").text("");
$("#chatWindow").empty();
$("#chatArea").hide();
});
})
/* styles.css */
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-family: Arial, sans-serif;
}
.container {
text-align: center;
width: 300px;
}
input[type="text"] {
padding: 8px;
margin-bottom: 10px;
font-size: 16px;
width: 100%;
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
margin-top: 10px;
}
.message {
margin-top: 20px;
font-size: 18px;
color: #333;
}
#chatArea {
margin-top: 20px;
display: none;
}
#chatWindow {
border: 1px solid #ddd;
height: 200px;
overflow-y: auto;
padding: 10px;
margin-bottom: 10px;
background-color: #f9f9f9;
}
.message-row {
display: flex;
margin: 5px 0;
}
.message-row .user-message {
background-color: #dcf8c6;
padding: 8px;
border-radius: 10px;
max-width: 70%;
align-self: flex-end;
margin-left: auto;
}
.message-row .other-message {
background-color: #ebebeb;
padding: 8px;
border-radius: 10px;
max-width: 70%;
align-self: flex-start;
margin-right: auto;
}
HTML, JavaSciprt, CSS는 ChatGPT의 도움을 받아서 만들었다.
stomp.js
라이브러리를 이용했고, 앞서 서버쪽에서 엔드포인트를 ws://localhost:8080/chat-sample
로 설정했으므로, 이를 이용하여 stompClient
을 만들었다.
자바스크립트 부분을 떼어놓고 보자
$("#sendButton").click(() => {
const chatInput = $("#chatInput").val().trim();
if (chatInput !== "" && stompClient) {
const message = {
name: username,
content: chatInput
};
// 서버로 메시지 전송
stompClient.publish({
destination: "/app/chat",
body: JSON.stringify(message)
});
displayMessage(username, chatInput, true);
$("#chatInput").val("");
}
});
이 부분은 사용자의 이름과 입력한 메시지를 이용해 /app/chat
경로로 메시지를 전송한다.
전송된 메시지는 prefix가 app
이므로, 브로커를 통해 MessageController
의 @MessageMapping("/chat")
으로 라우팅된다.
stompClient.onConnect = (frame) => {
stompClient.subscribe('/chat/sample', function (message) {
const receivedMessage = JSON.parse(message.body);
if(receivedMessage.name !== username){
displayMessage(receivedMessage.name, receivedMessage.content, false);
}
});
};
이 부분은 WebSocket 서버의 /chat/sample
을 구독하겠다는 것을 의미한다.
해당 경로에서 메시지가 발생하면 message
로 받아서 출력하는 내용을 담고있다.
@MessageMapping("/chat")
@SendTo("/chat/sample")
public Message chatMessage(Message message) throws Exception {
return message;
}
위 MessageController
의 메서드에 @SendTo("/chat/sample")
로 설정되어 있으므로,
클라이언트에서 /app/chat
으로 메시지 전달 -> /chat/sample
을 구독한 클라이언트 들에게 전송의 과정을 거친다
실제로 애플리케이션을 동작시켜서 개발자도구를 통해 WebSocket 연결을 확인해보자
// 요청
GET ws://localhost:8080/chat-sample HTTP/1.1
Host: localhost:8080
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36
Upgrade: websocket
Origin: http://localhost:8080
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9
Sec-WebSocket-Key: j9aWMij54Jqga9bsEYxTwA==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Protocol: v12.stomp, v11.stomp, v10.stomp
// 응답
HTTP/1.1 101
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: bwZduI5KtqArct8+9tQFH62/AVQ=
Sec-WebSocket-Protocol: v12.stomp
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15
Date: Fri, 15 Nov 2024 09:09:45 GMT
사용자가 연결 생성을 요청하면 관련 요청헤더와 응답헤더를 통해 연결을 생성한다
STOMP 메시지를 확인해보면 CONNECT, CONNECTED, SUBSCRIBE 를 통해 연결생성과 /chat/sample
구독에 대한 메시지를 확인할 수 있다.
이후, 메시지를 전송하면 SEND를 통해 메시지를 서버측으로 전송한다.
하단의 MESSAGE
는 현재 클라이언트가 /chat/sample
을 구독하고 있기때문에 서버에서 발생한 메시지를 받아왔다는 것을 의미한다. 다만, 유저가 동일한 경우 출력되지 않게 해놨으므로 출력되지 않는다.
두 클라이언트가 존재한다고 가정하고, 시크릿 창 두개를 활용해 테스트했을 때 실시간으로 메시지를 주고받는걸 확인할 수 있다.