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에 입장합니다.
메세지를 주고 받습니다.