Springboot- websocket을 이용한 채팅
WebSocket
기존의 단방향 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜.
일반 Socket통신과 달리 HTTP 80 Port를 사용하므로 방화벽에 제약이 없으며 통상 WebSocket으로 불린다.
접속까지는 HTTP 프로토콜을 이용하고, 그 이후 통신은 자체적인 WebSocket 프로토콜로 통신하게 된다.
HTTP와 달리 한번연결 후 데이터를 송수신하기 때문에 실시간채팅이나 스트리밍서비스에 사용된다.
Spring에서 websocket 서버 구현하기
build.gradle
//starter
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// json
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.14.2'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
WebsocketHandler
socket통신은 서버와 클라이언트가 1:N으로 관계를 맺습니다. 따라서 한 서버에 여러 클라이언트가 접속할 수 있으며,
서버에는 여러 클라이언트가 발송한 메시지를 받아 처리해줄 Handler의 작성이 필요합니다.
다음과 같이 TextWebSocketHandler를 상속받아 Handler를 작성해 줍니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class WebSockChatHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper;
private final ChatService chatService;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
ChatMessage chatMessage = objectMapper.readValue(payload, ChatMessage.class);
ChatRoom room = chatService.findRoomById(chatMessage.getRoomId());
Set<WebSocketSession> sessions=room.getSessions(); //방에 있는 현재 사용자 한명이 WebsocketSession
if (chatMessage.getType().equals(ChatMessage.MessageType.ENTER)) {
//사용자가 방에 입장하면 Enter메세지를 보내도록 해놓음. 이건 새로운사용자가 socket 연결한 것이랑은 다름.
//socket연결은 이 메세지 보내기전에 이미 되어있는 상태
sessions.add(session);
chatMessage.setMessage(chatMessage.getSender() + "님이 입장했습니다."); //TALK일 경우 msg가 있을 거고, ENTER일 경우 메세지 없으니까 message set
sendToEachSocket(sessions,new TextMessage(objectMapper.writeValueAsString(chatMessage)) );
}else if (chatMessage.getType().equals(ChatMessage.MessageType.QUIT)) {
sessions.remove(session);
chatMessage.setMessage(chatMessage.getSender() + "님이 퇴장했습니다..");
sendToEachSocket(sessions,new TextMessage(objectMapper.writeValueAsString(chatMessage)) );
}else {
sendToEachSocket(sessions,message ); //입장,퇴장 아닐 때는 클라이언트로부터 온 메세지 그대로 전달.
}
}
private void sendToEachSocket(Set<WebSocketSession> sessions, TextMessage message){
sessions.parallelStream().forEach( roomSession -> {
try {
roomSession.sendMessage(message);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
//javascript에서 session.close해서 연결 끊음. 그리고 이 메소드 실행.
//session은 연결 끊긴 session을 매개변수로 이거갖고 뭐 하세요.... 하고 제공해주는 것 뿐
}
}
일반적으로 채팅동작하는 원리는 다음과 같다.
handletextMessage 는 사용자(javascript에서 websocket 객체)가 send("메세지")를 실행하면 호출된다.서버는 한 사용자가 보낸 메세지(TextMessage message)를 다른사용자'들' 에게 보낸다.
여기서는 입장과 퇴장, 그 외를 나눠서 서로다른메세지를 세팅했지만, sendToEachSocket() 메소드를 통해 같은방에 있는 모든 사용자들에게 메세지를 보낸다.
WebsocketConfig
@RequiredArgsConstructor
@Configuration
@EnableWebSocket //이게 websocket 서버로서 동작하겠다는 어노테이션
public class WebSockConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
// handler 등록, js에서 new Websocket할 때 경로 지정
//다른 url에서도 접속할 수있게(CORS방지)
}
}
위에서 만든 handler를 이용하여 Websocket을 활성화하기 위한 Config 파일을 작성합니다.
@EnableWebSocket을 선언하여 Websocket을 활성화합니다. Websocket에 접속하기 위한 endpoint는 /ws/chat으로
설정하고 도메인이 다른 서버에서도 접속 가능하도록 CORS : setAllowedOrigins(“*”)를 설정을 추가해 줍니다.
이제 클라이언트가 ws://localhost:8080/ws/chat으로 커넥션을 연결하고 메시지 통신을 할 수 있는 기본적인 준비가
끝났습니다. 나머지는 채팅메세지를 위한 DTO, 채팅 방을 위한 ChatRoom 및 Chatroom 관련 service
그리고 화면을 위한 Controller, html 등만 작성하면 됩니다.
ChatRoom
@Getter
public class ChatRoom {
private String roomId;
private String name;
private Set<WebSocketSession> sessions = new HashSet<>();
@Builder
public ChatRoom(String roomId, String name) {
this.roomId = roomId;
this.name = name;
}
}
특별할 건 없지만 방 한개마다 여러사용자들을 Set형태로 가지고 있습니다.
ChatMessage
@Getter
@Setter
public class ChatMessage {
// 메시지 타입 : 입장, 채팅, 나감
public enum MessageType {
ENTER, TALK,QUIT
}
private MessageType type; // 메시지 타입
private String roomId; // 방번호
private String sender; // 메시지 보낸사람
private String message; // 메시지
}
Enum인 MessageType은 서버가 메세지를 처리할 때 입장, 채팅, 퇴장을 구별하는데 사용됩니다.
ChatService
@Slf4j
@RequiredArgsConstructor
@Service
public class ChatService {
private final ObjectMapper objectMapper;
private Map<String, ChatRoom> chatRooms;
@PostConstruct
private void init() {
chatRooms = new LinkedHashMap<>();
}
public List<ChatRoom> findAllRoom() {
return new ArrayList<>(chatRooms.values());
}
public ChatRoom findRoomById(String roomId) {
return chatRooms.get(roomId);
}
public ChatRoom createRoom(String name) {
String randomId = UUID.randomUUID().toString();
ChatRoom chatRoom = ChatRoom.builder()
.roomId(randomId)
.name(name)
.build();
chatRooms.put(randomId, chatRoom);
return chatRoom;
}
}
방을 만드는것과 방을 찾아주는 메소드가 있습니다.
방을 만들 때는 random한 방 이름으로 만듭니다.
DB와 연동된다면 방을 DB에 저장하겠지만, 아직은 DB연결이 없기때문에 Map형태로 방을 저장합니다.
ChatController
@Controller
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
@RequestMapping("/chat/chatList")
public String chatList(Model model){
List<ChatRoom> roomList = chatService.findAllRoom();
model.addAttribute("roomList",roomList);
return "chat/chatList";
}
@PostMapping("/chat/createRoom") //방을 만들었으면 해당 방으로 가야지.
public String createRoom(Model model, @RequestParam String name, String username) {
ChatRoom room = chatService.createRoom(name);
model.addAttribute("room",room);
model.addAttribute("username",username);
return "chat/chatRoom"; //만든사람이 채팅방 1빠로 들어가게 됩니다
}
@GetMapping("/chat/chatRoom")
public String chatRoom(Model model, @RequestParam String roomId){
ChatRoom room = chatService.findRoomById(roomId);
model.addAttribute("room",room); //현재 방에 들어오기위해서 필요한데...... 접속자 수 등등은 실시간으로 보여줘야 돼서 여기서는 못함
return "chat/chatRoom";
}
}
chatList.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/chat/createRoom" method="post">
<input type="text" name="name" placeholder="채팅방 이름">
<button type="submit" >방 만들기</button>
</form>
<table>
<tr th:each="room : ${roomList}" >
<td>
<a th:href="@{chatRoom(roomId=${room.roomId} )}"
th:text="${room.name}"></a>
</td>
</tr>
</table>
</body>
</html>
첫 화면은 현재 만들어진 방을 전부 보여줍니다.
방을 만들면 만든 방 주소로 입장하게됩니다.
chatRoom.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<input type="text" placeholder="보낼 메세지를 입력하세요." class="content">
<button type="button" value="전송" class="sendBtn" onclick="sendMsg()">전송</button>
<button type="button" value="방나가기" class="quit" onclick="quit()">방 나가기 </button>
<div>
<span>메세지</span>
<div class="msgArea"></div>
</div>
</body>
<script th:inline="javascript">
function enterRoom(socket){
var enterMsg={"type" : "ENTER","roomId":[[${room.roomId}]],"sender":"chee","msg":""}; //sender는 글쓸때 수정하자.
socket.send(JSON.stringify(enterMsg));
}
let socket = new WebSocket("ws://localhost:8080/ws/chat");
socket.onopen = function (e) {
console.log('open server!')
enterRoom(socket);
};
socket.onclose=function(e){
console.log('disconnet');
}
socket.onerror = function (e){
console.log(e);
}
//메세지 수신했을 때 이벤트.
socket.onmessage = function (e) {
console.log(e.data);
let msgArea = document.querySelector('.msgArea');
let newMsg = document.createElement('div');
newMsg.innerText=e.data;
msgArea.append(newMsg);
}
//메세지 보내기 버튼 눌렀을 떄..
function sendMsg() {
let content=document.querySelector('.content').value;
var talkMsg={"type" : "TALK","roomId":[[${room.roomId}]] ,"sender":"chee","msg":content};
socket.send(JSON.stringify(talkMsg));
}
function quit(){
var quitMsg={"type" : "QUIT","roomId":[[${room.roomId}]] ,"sender":"chee","msg":""};
socket.send(JSON.stringify(quitMsg));
socket.close();
location.href="/chat/chatList";
}
</script>
</html>
시작하자마자 webSocket을 만들고 연결합니다. 그 후 방에 입장( 입장메세지를 보냄) 합니다.
메세지를 수신하면 div에 해당 내용이 추가되고
전송메세지를 누를 때마다 서버에 메세지를 보냅니다.
아직은 메세지를 보내는 사람 이름을 "chee"로만 설정합니다
방 나가기를 누르면 방을 나갑니다.
(아직은 뒤로가기나 브라우저를 끄는 등의 행위를 할 때 방 나가기 이벤트가 실행되지 않기때문에
방 나가기 버튼을 직접 눌러 실습합니다)
실습

방을 만들었다면 방에 입장합니다. 실시간 채팅이므로 브라우저를 2개이상 켜놓고 실습합니다.
각 브라우저에서 방1에 입장합니다.

메세지를 주고 받습니다.
