
이번 포스팅에서는 WebSocket을 이용하여 사용자와 관리자가 실시간으로 채팅하는 1:M
채팅 시스템을 구현하는 방법을 소개합니다. 이 기능은 관리자와 다수의 사용자 간에 실시간으로 메시지를 주고받을 수 있는 환경을 제공해요.
먼저, 서버에서 WebSocket을 설정해야 합니다. 이를 위해 Spring Boot에서 WebSocketConfigurer 인터페이스를 구현하고, WebSocket 핸들러들을 등록하는 설정 클래스를 만들어줍니다.
package com.my.interrior.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import com.my.interrior.admin.chat.Chat;
import com.my.interrior.client.chat.BroadSocket;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final BroadSocket broadSocket;
private final Chat chat;
@Autowired
public WebSocketConfig(BroadSocket broadSocket, Chat chat) {
this.broadSocket = broadSocket;
this.chat = chat;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chat, "/adminBroadsocket").setAllowedOrigins("*");
registry.addHandler(broadSocket, "/broadsocket").setAllowedOrigins("*");
}
}
두 개의 WebSocket 핸들러를 설정했습니다.
/adminBroadSocket은 관리자를 위한 핸들러이고
/broadSocket은 사용자들을 위한 핸들러입니다.
BroadSocket과 Chat 클래스는 각각 사용자와 관리자가 메시지를 주고받는 WebSocket 핸들러로 작동하게 됩니다.
사용자 측에서는 broadsocket 경로로 WebSocket을 연결하여, 서버로부터 실시간 메시지를 받을 수 있습니다.
<script>
// 서버의 broadsocket 서블릿으로 웹 소켓 시작
var webSocket = new WebSocket(`wss://${window.location.host}/broadsocket`);
var messageTextArea = document.getElementById("messageTextArea");
// 접속이 완료되면 콘솔에 메시지를 남긴다.
webSocket.onopen = function (message) {
messageTextArea.value += "접속이 완료되었습니다.\n";
};
// 서버로부터 메시지가 도착하면 콘솔에 메시지를 남긴다.
webSocket.onmessage = function (message) {
messageTextArea.value += "(operator) => " + message.data + "\n";
};
// 메시지 전송 함수
function sendMessage() {
var message = document.getElementById('textMessage');
messageTextArea.value += "(me) => " + message.value + "\n";
webSocket.send(message.value);
message.value = "";
}
// 엔터 키로 메시지 전송
function enter() {
if (event.keyCode === 13) {
sendMessage();
return false;
}
return true;
}
</script>
저희는 https로 무료 도메인 따서 만들었기 때문에
var webSocket = new WebSocket(`wss://${window.location.host}/broadsocket`);
보통 localhost로 하실 때에는
var webSocket = new WebSocket(`ws://${window.location.host}/my_url`);
ws는 WebSocket 프로토콜의 약자입니다.
WebSocket은 클라이언트(브라우저)와 서버 간의 양방향 통신을 가능하게 해주는 프로토콜로, 서버와 클라이언트 간의 실시간 데이터 송수신이 가능합니다.
ws는 HTTP 프로토콜이 아닌 TCP를 기반으로 동작하며, 기본적으로 비보안 통신을 의미합니다.
wss는 WebSocket Secure의 약자입니다.
ws와 동일한 WebSocket 통신을 수행하지만, 보안이 강화된 통신입니다. wss는 HTTPS처럼 SSL/TLS를 사용하여 데이터를 암호화해 전송합니다.
웹 애플리케이션에서 보안을 강화하고 싶다면, wss를 사용하는 것이 좋습니다.
${window.location.host}는 자바스크립트에서 현재 웹 페이지가 실행 중인 호스트 주소를 가져오는 방식입니다.
window.location은 브라우저의 현재 URL 정보를 담고 있는 객체이며, 그 중에서 host는 현재 URL의 도메인과 포트 번호를 포함한 부분입니다.
예를 들어, 현재 URL이 http://localhost:8080/my-page라면, window.location.host는 localhost:8080을 반환합니다.
이를 템플릿 리터럴 ${}로 감싸서 동적으로 현재 호스트 정보를 사용하게 할 수 있습니다.
위 코드는 사용자가 서버에 연결되고 메시지를 주고받는 과정을 나타냅니다. 서버로부터 메시지가 오면 콘솔에 (operator) => 라벨이 붙은 메시지가 출력되고, 사용자가 메시지를 입력하면 (me) => 라벨이 붙은 메시지가 전송됩니다.
관리자는 /adminBroadsocket 경로로 WebSocket을 연결하여 여러 사용자와 실시간 채팅을 할 수 있습니다. 아래는 관리자 측에서의 WebSocket 연결을 구현한 JavaScript 코드입니다.
<script>
var webSocket = new WebSocket(`wss://${window.location.host}/adminBroadsocket`);
console.log("window.location.host: ", window.location.host);
webSocket.onmessage = function (message) {
var node = JSON.parse(message.data);
if (node.status === "visit") {
var form = $('.template').html();
form = $("<div class = 'float-left'></div>").attr("data-key", node.key).append(form);
$("body").append(form);
} else if (node.status === "message") {
var $div = $("[data-key='" + node.key + "']");
var log = $div.find(".console").val();
$div.find(".console").val(log + "(user) => " + node.message + "\n");
} else if (node.status === "bye") {
$("[data-key='" + node.key + "']").remove();
}
};
$(document).on("click", ".sendBtn", function () {
var $div = $(this).closest(".float-left");
var message = $div.find(".message").val();
var key = $div.data("key");
var log = $div.find(".console").val();
$div.find(".console").val(log + "(me) => " + message + "\n");
$div.find(".message").val("");
webSocket.send(key + "#####" + message);
});
$(document).on("keydown", ".message", function () {
if (event.keyCode === 13) {
$(this).closest(".float-left").find(".sendBtn").trigger("click");
return false;
}
return true;
});
</script>
관리자는 사용자들이 접속할 때마다 새로운 채팅 창을 생성하며, 각 사용자가 보낸 메시지는 그들의 고유 키에 따라 구분됩니다. 또한, 관리자는 각 사용자에게 별도로 메시지를 전송할 수 있습니다.
Chat 클래스는 관리자가 접속하는 WebSocket 핸들러입니다. 관리자가 접속되면 이 세션을 저장하고, 이후 사용자들의 메시지를 처리하거나 접속 상태를 관리자에게 전달합니다.
@Component
public class Chat extends TextWebSocketHandler {
private WebSocketSession adminSession = null; // 관리자 세션을 저장할 변수
private final BroadSocket broadSocket; // 사용자 소켓 객체 참조
@Autowired
//Chat과 BroadSocket은 서로를 참조하고 있어 순환 참조를 방지하기 위해 @Lazy 사용
public Chat(@Lazy BroadSocket broadSocket) {
this.broadSocket = broadSocket;
}
// 관리자가 접속했을 때 처리, WebSocket 연결이 성립되었을 때 호출된다.
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
if (adminSession == null) { // 관리자 세션이 없을 때만 저장
adminSession = session;
// 현재 접속 중인 모든 사용자 정보를 관리자에게 전송
for (String key : broadSocket.visitAllUsers()) {
visit(key);
}
}
}
// 관리자가 보낸 메시지를 처리
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
try {
String payload = message.getPayload();
String[] split = payload.split("#####", 2); // 메시지를 구분자 #####로 분리
if (split.length >= 2) {
String key = split[0]; // 사용자 키
String msg = split[1]; // 실제 메시지
broadSocket.sendMessage(key, msg); // 특정 사용자에게 메시지 전송
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 관리자가 접속을 종료했을 때 처리
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
if (session == adminSession) {
adminSession = null; // 관리자가 접속을 종료하면 세션을 초기화
}
}
// 관리자에게 메시지를 전송하는 함수
public void sendToAdmin(String message) {
if (adminSession != null && adminSession.isOpen()) {
try {
adminSession.sendMessage(new TextMessage(message));
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 일반 사용자가 접속했을 때, 관리자에게 알림
public void visit(String key) {
sendToAdmin("{\"status\":\"visit\", \"key\":\"" + key + "\"}");
}
// 일반 사용자가 메시지를 보냈을 때, 관리자에게 알림
public void sendMessage(String key, String message) {
sendToAdmin("{\"status\":\"message\", \"key\":\"" + key + "\", \"message\":\"" + message + "\"}");
}
// 사용자가 접속을 끊을 때 관리자에게 알림
public void bye(String key) {
sendToAdmin("{\"status\":\"bye\", \"key\":\"" + key + "\"}");
}
}
// 관리자가 접속했을 때 처리, WebSocket 연결이 성립되었을 때 호출된다.
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
if (adminSession == null) { // 관리자 세션이 없을 때만 저장
adminSession = session;
// 현재 접속 중인 모든 사용자 정보를 관리자에게 전송
for (String key : broadSocket.visitAllUsers()) {
visit(key);
}
}
}
관리자가 처음 연결할 때, 이 세션을 adminSession에 저장해두고 기존에 접속해 있는 사용자들의 정보를 관리자가 확인할 수 있도록 visit 메서드를 호출해 사용자 목록을 관리자로 보냅니다.
visitAllUsers() 메서드는 현재 접속해 있는 모든 유저의 키(key)를 가져와 관리자가 접속하자마자 그 정보를 전달합니다.
String[] split = payload.split("#####", 2);
if (split.length >= 2) {
String key = split[0];
String msg = split[1];
broadSocket.sendMessage(key, msg);
}
이 메서드는 WebSocket으로 메시지가 전달될 때 호출됩니다.
메시지를 전달받으면 이를 파싱하여 ##### 기준으로 나눕니다. 첫 번째 부분은 사용자 키(key)이고, 두 번째 부분은 메시지 내용입니다.
이 정보를 통해 특정 사용자에게 메시지를 전송합니다.
broadSocket.sendMessage(key, msg)는 BroadSocket 클래스로 메시지를 전달하여 특정 사용자에게 보낼 수 있게 합니다.
// 관리자가 접속을 종료했을 때 처리
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
if (session == adminSession) {
adminSession = null; // 관리자가 접속을 종료하면 세션을 초기화
}
}
관리자가 WebSocket 연결을 끊을 때 호출됩니다. 연결이 끊기면 adminSession을 null로 초기화하여 다음에 관리자가 접속할 수 있도록 준비합니다.
package com.my.interrior.client.chat;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import com.my.interrior.admin.chat.Chat;
@Component
public class BroadSocket extends TextWebSocketHandler {
// 서버와 유저간의 접속을 key로 구분하기 위한 내부 클래스
private static class User {
WebSocketSession session;
String key;
}
private final Chat chat;
// 유저와 서버간의 접속 리스트
private List<User> sessionUsers = Collections.synchronizedList(new ArrayList<>());
@Autowired
public BroadSocket(@Lazy Chat chat) {
this.chat = chat;
}
// Session으로 접속 리스트에서 User 클래스를 탐색
private User getUser(WebSocketSession session) {
return searchUser(x -> x.session == session);
}
// key로 접속 리스트에서 User 클래스를 탐색
private User getUser(String key) {
return searchUser(x -> x.key.equals(key));
}
// 접속 리스트 탐색 함수
private User searchUser(SearchExpression func) {
Optional<User> op = sessionUsers.stream().filter(x -> func.expression(x)).findFirst();
return op.orElse(null);
}
// WebSocket 연결이 열릴 때 실행됨
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 인라인 클래스 User를 생성
User user = new User();
// Unique 키를 발급("-"는 제거)
user.key = UUID.randomUUID().toString().replace("-", "");
// WebSocket의 세션
user.session = session;
// 유저 리스트에 등록
sessionUsers.add(user);
// 관리자한테 알리기
chat.visit(user.key); // Chat 클래스의 visit 메서드 호출
}
// WebSocket으로 메시지가 왔을 때 실행됨
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// Session으로 접속 리스트에서 User 클래스를 탐색하기
User user = getUser(session);
System.out.println("클라이언트에서 관리자로 보내는 메시지는? : " + message);
// 접속 리스트에 User가 없으면
if (user == null) {
chat.bye(user.key); // Chat 클래스의 bye 메서드 호출
sessionUsers.remove(user);
}else {
//여기에 클라이언트에서 관리자로 메시지 보내는 로직 작성
chat.sendMessage(user.key, message.getPayload());
}
}
// WebSocket 연결이 닫힐 때 실행됨
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
User user = getUser(session);
if (user != null) {
sessionUsers.remove(user);
chat.bye(user.key); // Chat 클래스의 bye 메서드 호출
}
}
// 관리자에게 메시지 보내는 함수
public void sendMessage(String key, String message) {
// key로 접속 리스트에서 User 클래스를 탐색
System.out.println("관리자에서 클라이언트로 보내는 메시지: " + message);
User user = getUser(key);
if (user != null) {
try {
user.session.sendMessage(new TextMessage(message));
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 모든 유저의 key를 가져와서 List로 반환
public List<String> visitAllUsers() {
List<String> keys = new ArrayList<>();
sessionUsers.forEach(user -> {
keys.add(user.key);
});
return keys;
}
// 내부 인터페이스: 접속 리스트 필터링을 위한 표현식 인터페이스
private interface SearchExpression {
boolean expression(User user);
}
}
// WebSocket 연결이 열릴 때 실행됨
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 인라인 클래스 User를 생성
User user = new User();
// Unique 키를 발급("-"는 제거)
user.key = UUID.randomUUID().toString().replace("-", "");
// WebSocket의 세션
user.session = session;
// 유저 리스트에 등록
sessionUsers.add(user);
// 관리자한테 알리기
chat.visit(user.key); // Chat 클래스의 visit 메서드 호출
}
// WebSocket으로 메시지가 왔을 때 실행됨
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// Session으로 접속 리스트에서 User 클래스를 탐색하기
User user = getUser(session);
System.out.println("클라이언트에서 관리자로 보내는 메시지는? : " + message);
// 접속 리스트에 User가 없으면
if (user == null) {
chat.bye(user.key); // Chat 클래스의 bye 메서드 호출
sessionUsers.remove(user);
}else {
//여기에 클라이언트에서 관리자로 메시지 보내는 로직 작성
chat.sendMessage(user.key, message.getPayload());
}
}
// WebSocket 연결이 닫힐 때 실행됨
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
User user = getUser(session);
if (user != null) {
sessionUsers.remove(user);
chat.bye(user.key); // Chat 클래스의 bye 메서드 호출
}
}
if (adminSession != null && adminSession.isOpen()) {
adminSession.sendMessage(new TextMessage(message));
}
sendToAdmin("{\"status\":\"visit\", \"key\":\"" + key + "\"}");
sendToAdmin("{\"status\":\"bye\", \"key\":\"" + key + "\"}");
User user = getUser(key);
if (user != null) {
user.session.sendMessage(new TextMessage(message));
}
이 구현에서는 WebSocket을 이용하여 관리자와 여러 사용자 간의 실시간 채팅을 구현했습니다. Chat 클래스는 관리자의 역할을 처리하고, BroadSocket 클래스는 사용자들의 연결과 메시지 송수신을 관리합니다. @Lazy 어노테이션을 통해 순환 참조 문제를 해결했으며, 각종 메시지 전송 및 연결 종료 등의 이벤트를 처리하는 메서드들을 통해 관리자와 사용자가 원활하게 소통할 수 있도록 설계되었습니다.