이번 포스팅에서는 WebSocket 라이브러리와 SockJS 라이브러리를 이용해 스프링 채팅 서비스를 구현해보겠습니다.
WebSocket ?
: 웹 버전의 TCP 또는 Socket 이라고 이해하면 됩니다. WebSocket은 서버와 클라이언트 간 Socket Connection을 유지해서 언제든 양방향 통신 또는 데이터 전송이 가능하도록 하는 기술입니다.
ex) SNS 애플리케이션 , LoL 같은 멀티플레이어 게임, 증권 거래 , 화상채팅 등
WebSocket 의 동작 과정
1. TCP/IP 접속 요청 (클라이언트)
2. TCP/IP 접속 수락 (서버)
3. 웹소켓 열기 핸드쉐이크 요청 (클라이언트)
4. 웹소켓 열기 핸드쉐이크 수락 (서버)
5. 웹소켓 데이터 송, 수신 (클라이언트, 서버)
SockJS ?
- 순수 WebSocket 만으로 채팅을 구현하게 되면, Firefox, Chrome, Egde , Whale 에서 동작합니다.
- IE와 같이 일부 브라우저에서는 동작하지 않는 문제가 발생하게 됩니다.
- 해결방법으로 WebSocket Emulation을 이용하면 됩니다.
- WebSocket Emulation이란 우선 WebSocket을 시도하고 실패할 경우 HTTP Streaming , Long-Polling 같은 HTTP 기반의 다른 기술로 전환해 다시 연결을 시도하는 것을 말합니다.
- 이때 Spring을 사용한다면 보통 SockJS 라이브러리를 이용하는 것이 일반적입니다.
채팅 서비스 구현
의존성 추가 - build.gradle
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.1.2'
WebSocketHandler
@Slf4j
@Component
public class WebSocketHandler extends TextWebSocketHandler {
private static final ConcurrentLinkedQueue<WebSocketSession> sessions= new ConcurrentLinkedQueue<>();
// 메세지 처리하는 메소드
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("payload : " + payload);
for(WebSocketSession sess: sessions) {
sess.sendMessage(message);
}
}
// client 접속 시 호출되는 메서드
@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
log.info(session + " 클라이언트 접속");
}
// client 접속 해제 시 호출되는 메서드드
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
log.info(session + " 클라이언트 접속 해제");
sessions.remove(session);
}
}
- WebSocketSession 객체들을 저장하는 ConcurrentLinkedQueue를 선언해준다. 이는 각 클라이언트와의 연결 정보를 담고 있습니다.
- 메세지가 도착했을 때 호출되는 handleTextMessage() 메소드를 구현하여 메세지를 받아와 모든 연결된 클라이언트에게 브로드캐스팅 합니다.
- 클라이언트가 연결되었을 때 호출되는 afterConnectionEstablished() 메소드를 구현하여 새로운 클라이언트가 연결되면 해당 세션을 sessions 큐에 추가합니다.
- 클라이언트가 연결을 종료했을 때 호출되는 afterConnectionClosed() 메소드를 구현하여 해당 세션을 sessions 큐에서 제거합니다.
ConcurrentLinkedQueue 를 사용한 이유
:ConcurrentLinkedQueue는 멀티스레드 환경에서 안전하게 사용할 수 있는 큐 자료구조입니다. 이 코드에서 sessions 변수는 서버에 연결된 클라이언트 세션을 저장하는 역할을 하고, 이런 상황에서는 여러 클라이언트가 동시에 접속하거나 연결을 해제할 수 있기 때문에 멀티스레드 환경에서 안전한 자료구조를 사용하는 것이 좋습니다.
WebSocketConfig
- 이전에 구현한 WebSocketHandler을 이용해 WebSocket을 활성화하기 위한 Config 입니다.
- @EnableWebSocket 애노테이션을 사용하여 WebSocket을 활성화 하도록 합니다.
- WebSocket에 접속하기 위한 Endpoint는 /ws/chat으로 설정합니다.
- 도메인이 다른 서버에서 접속 가능하도록 CORS : setAllowedOrigins("*");를 추가해줍니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/ws/chat")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
EndPoint 란?
: EndPoint는 API가 서버에서 리소스에 접근할 수 있도록 하는 URL 입니다.
CORS 란 ?
: 교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여 한 출처에서 실행 중인 웹 어플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다.
웹 애플리케이션은 리소스가 자신의 출처(domain, protocol, port)와 다를 시에 교차 출처 HTTP 요청을 실행합니다. 이에 대한 응답으로 서버는 Access-Control-Allow-Origin 헤더를 다시 보냅니다.
ChatController
@RequiredArgsConstructor
@RequestMapping("/chat")
@Controller
public class ChatRoomController {
@GetMapping("/{username}")
public String chat(@PathVariable String username, Model model){
model.addAttribute("username", username);
return "/chat";
}
}
- {username}을 통해 채팅방에 입장하는 자신의 닉네임을 지정하여 채팅방에 입장합니다.
Chat.html
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<!-- SockJS 및 Stomp.js 스크립트 로드 -->
<script src="https://cdn.jsdelivr.net/webjars/sockjs-client/1.0.2/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/webjars/stomp-websocket/2.3.3/stomp.min.js"></script>
</head>
</head>
<body>
... body code
</body>
</html>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// 실행할 기능을 정의해주세요.
let username = /*[[${username}]]*/ ''; // 모델의 username 값을 JavaScript 변수로 가져옴
console.log('Username: ' + username);
let msgArea = document.getElementById("msgArea");
console.log("maxArea = ", msgArea);
// 버튼 클릭 이벤트 설정
let exitButton = document.getElementById("button-exit");
exitButton.addEventListener("click", function (e) {
disconnect();
});
let sendButton = document.getElementById("button-send");
sendButton.addEventListener("click", function (e) {
send();
});
let websocket = new SockJS("http://localhost:8080/ws/chat", null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});
websocket.onmessage = onMessage;
websocket.onopen = onOpen;
websocket.onclose = onClose;
function send() {
var msg = document.getElementById("msg");
console.log(username + ":" + msg.value);
websocket.send(username + ":" + msg.value);
msg.value = '';
}
function disconnect() {
let str = username + ": 님이 채팅을 종료했습니다.";
websocket.send(str);
websocket.close();
}
//채팅창에서 나갔을 때
function onClose(evt) {
let str = username + ": 님이 방을 나가셨습니다.";
websocket.send(str);
}
//채팅창에 들어왔을 때
function onOpen(evt) {
let str = username + ": 님이 입장하셨습니다.";
websocket.send(str);
}
function onMessage(msg) {
let data = msg.data;
let sessionId = null;
//데이터를 보낸 사람
let message = null;
let arr = data.split(":");
for (let i = 0; i < arr.length; i++) {
console.log('arr[' + i + ']: ' + arr[i]);
}
let cur_session = username;
//현재 세션에 로그인 한 사람
console.log("cur_session : " + cur_session);
sessionId = arr[0];
message = arr[1];
console.log("sessionID : " + sessionId);
console.log("cur_session : " + cur_session);
//로그인 한 클라이언트와 타 클라이언트를 분류하기 위함
if (sessionId == cur_session) {
let div = document.createElement('div');
div.classList.add('alert');
div.classList.add('alert-secondary');
let b = document.createElement('b');
b.innerText = sessionId + " : " + message;
div.appendChild(b);
msgArea.append(div);
} else {
let div = document.createElement('div');
div.classList.add('alert');
div.classList.add('alert-warning');
let b = document.createElement('b');
b.innerText = sessionId + " : " + message;
div.appendChild(b);
msgArea.append(div);
}
}
});
</script>
결과