WebSocket이란?
클라이언트와 서버간의 연결을 길게 유지하고, 이 연결을 통하여 서로에게 데이터를 전송할 수 있도록 해주는 통신 규약의 일종입니다.
서버에서 클라이언트에 데이터를 보낼 수 있다는 점에서 채팅, 알림 등의 기능을 제공하기 위해서 활용합니다.
클라이언트와 서버가 WebSocket 연결을 구성하는 과정
1. 클라이언트가 서버에게 HTTP 요청을 보내면서, WebSocket 통신을 사용하고자 하는 의도를 전달합니다. 이 의도는 HTTP Header Upgrade에 담겨 전송됩니다.
2. 서버가 클라이언트에게 WebSocket 통신의 지원여부에 따라 101 응답을 전송합니다.
101이란? Switching Protocols를 의미하는 Status Code입니다.
이후 서버와 클라이언트는 성공적으로 WebSocket통신을 활용하여 데이터를 주고 받을 수 있습니다. 이렇게 통신 규약을 바꾸는 과정을 Protocol Handshake라고 부릅니다.
이후에는 사용자도 서버도 원하는 시점에 데이터를 보낼 수 있습니다. 이떄 서로 데이터가 도달하는 시점에 어떤 동작을 할지를 정의하는 방식으로 기능을 구현하게 됩니다. 이런 사건에 따라 프로그램의 동작을 정하는 방식의 프로그래밍을 Event Driven Programming이라고 부릅니다.
WebSocket의 특징
1. 양방향 통신
2. 실시간 네트워킹
3. 최초 접속시에만 http 프로토콜 위에서 handshaking을 하기 때문에 http header를 사용한다.
4. 웹소켓을 위한 별도의 포트는 없고, 기존 포트를 사용한다.
5. 프레임으로 구성된 메시지라는 논리적 단위로 송수신한다.
6. 메시지에 포함될 수 있는 교환 가능한 메시지는 텍스트와 바이너리뿐이다.
01. WebSocket으로 채팅 기능 구현하기
ChatMessage DTO 생성
@Data
@AllArgsConstructor
public class ChatMessage {
private String username;
private String message;
}
SimpleChatHandler 클래스 생성
@Component
@Slf4j
public class SimpleChatHandler extends TextWebSocketHandler {
// 현재 연결되어 있는 클라이언트를 관리하기 위한 리스트
private final List<WebSocketSession> sessions
= new ArrayList<>();
//사용자 이름으로 세션을 구분하려면?
private Map<String, WebSocketSession> sessionByUserName;
public void broadcast(String message) throws Exception{
for (WebSocketSession connected : sessions) {
connected.sendMessage(new TextMessage(message));
}
}
@Override
// websocket이 연결될 때 실행된다.
public void afterConnectionEstablished(
WebSocketSession session
) throws Exception {
// 방금 참여한 사용자를 저장
sessions.add(session);
log.info("connected with session id: {}, total sessions: {}", session.getId(), sessions.size());
}
@Override
// websocket 메세지를 받으면 실행
protected void handleTextMessage(
WebSocketSession session,
TextMessage message
) throws Exception{
String payload = message.getPayload();
ChatMessage chatMessage = new Gson().fromJson(payload, ChatMessage.class);
log.info("received: {}", payload);
for (WebSocketSession connected:sessions
) {
connected.sendMessage(message);
}
}
@Override
// websocket 연결이 종료 되었을 때
public void afterConnectionClosed(
WebSocketSession session,
CloseStatus status
) throws Exception {
log.info("connection with {} closed", session.getId());
// 더이상 세션 객체를 보유하지 않도록
sessions.remove(session);
}
}
-> 이 클래스는 WebSocket을 요청 받았을 때 어떻게 행동할지를 정의하는 클래스
spring에서 제공하는 WebSocketHandler 인터페이스를 구현함으로서 진행한다.
1. TextWebSocketHandler : 웹 소켓 통신을 통해 문자 데이터만 받을 때 활용할 수 있는 WebSocketHandler 구현체이다.
채팅을 목적으로 할 경우 유용하게 활용 할 수 있다.
2. afterConnectionEstablished : 클라이언트가 웹소켓 연결을 구성할 때 호출되며, 연결된 클라이언트를 나타내는 webSocketSession객체를 전달해준다.
3. handleTextMessage : 이 메소드는 클라이언트 측에서 먼저 데이터를 보냈을 때 호출되는 메소드로, 메시지를 보낸 클라이언트는 websoketSession에, 전달한 데이터는 textmessage에 담기게 됩니다. 지금 작성한 메소드는 전달받은 텍스트 메시지를 sessions 리스트에 저장되어 있는 모든 websocketsession에 다시 전달하는 모습입니다.
4. afterConnectionClosed : 웹 소켓이 연결이 종료되면 호출하는 메소드입니다. 이때 등록된 sessions 정보에서 삭제를 진행하는데, 연결이 종료된 session의 메소드를 호출할 수 없기 때문입니다.
WebSocketConfig 인터페이스 생성
위에서 작성한 Handler를 웹 소켓 통신에 사용하겠다는 설정을 진행해줍니다. 이를 위해서 WebSocketConfig 인터페이스를 구현하여
WebSocketHandlerRegistry에 등록을 진행합니다.
- @Configuration, @EnableWebSocket 생성
- Handler의 객체를 "ws/chat" 경로에 등록한다.
- 모든 클라이언트가 접근할 수 있도록 .setAllowedOrigins("*")로 설정한다.
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final SimpleChatHandler simpleChatHandler;
public WebSocketConfig(
SimpleChatHandler simpleChatHandler
) {
this.simpleChatHandler = simpleChatHandler;
}
@Override
// WebSocketHandler 객체를 등록하기 위한 메소드
// 어떤 주소에 어떤 핸들러를 활용할지를 정의하는 메소드
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(simpleChatHandler, "ws/chat")
.setAllowedOrigins("*");
}
}
이제 채팅방 입장을 위한 rooms.html과, 실제 채팅을 진행하기 위한 chat.html을 작성 후 반환하는 컨트롤러를 작성해서 채팅방에 입장하여 채팅을 진행할 수 있습니다.
1. rooms.html 코드
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form id="enter-form">
<label for="username">
이름:
<input id="username" type="text">
</label>
<button id="enter-button" type="submit">입장</button>
</form>
<script>
document.getElementById("enter-form").addEventListener("submit", event => {
event.preventDefault();
const username = document.getElementById("username").value
location.href = `/chat/enter?username=${username}`
})
</script>
</body>
</html>
2. chat.html 코드
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebSocket Chatting</title>
</head>
<body>
<h3 id="room-name"></h3>
<div id="conversation">
<div id="response"></div>
<form id="chat-form">
<h4>
이름: <span id="username-holder"></span>
</h4>
<input type="text" id="message" placeholder="Write a message..."/>
<button type="submit">Send</button>
</form>
</div>
<script>
const username = (new URLSearchParams(location.search)).get("username")
document.getElementById("username-holder").innerText = username
const webSocket = new WebSocket("ws://localhost:8080/ws/chat")
webSocket.onopen = (event) => {
console.log(event)
webSocket.send(JSON.stringify({
username,
message: `${username} 입장`
}))
}
webSocket.onmessage = (msg) => {
console.log(msg)
const data = JSON.parse(msg.data)
const chatMessage = document.createElement("div")
const message = document.createElement("p")
message.innerText = data.username + ": " + data.message;
chatMessage.appendChild(message)
document.getElementById("response").appendChild(chatMessage)
}
webSocket.onclose = (event) => {
console.log(event)
webSocket.send(JSON.stringify({
username,
message: `${username} 퇴장`
}))
}
document.getElementById("chat-form").addEventListener("submit", e => {
e.preventDefault()
const messageInput = document.getElementById("message")
const message = messageInput.value
webSocket.send(JSON.stringify({
username, message
}))
messageInput.value = ""
})
</script>
</body>
</html>
ChatController 생성
@Controller
@RequestMapping("chat")
@RequiredArgsConstructor
public class ChatController {
private final SimpleChatHandler simpleChatHandler;
private final Gson gson;
@GetMapping("test")
public @ResponseBody String test() throws Exception {
simpleChatHandler.broadcast(gson.toJson(new ChatMessage("admin", "10분 뒤 서버가 종료됩니다.")));
return "done";
}
@GetMapping("rooms")
public String rooms() {
return "rooms";
}
@GetMapping("enter")
public String enter(@RequestParam("username") String username) {
return "chat";
}
}
채팅 기능 실습
http://localhost:8080/chat/test 접속하면 채팅방에 "admin : 10분 뒤 서버가 종료됩니다." 공지 올라감
02. STOMP over WebSocket
STOMP는 Streaming Text Oriented Messaging Protocol의 약자로, 단순히 데이터만 보내는 통신 규약인 웹 소켓 통신 과정에서, HTTP와 같은 구조의 메세지 형식을 갖추어서 보내도록 하는 통신 규약입니다.
하나의 URL에 연결한 모든 세션을 조정할 필요없이, 특정 세션에만 메세지를 보내는 방식으로 만들기 편해집니다.
이번에는 STOMP를 사용하여 주고 받는 메세지를 좀 더 구조화하고 전송하는 포로토콜을 실습해봅니다.
WebSocketStompConfig 클래스 생성
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
@Override
// STOMP 엔드포인트 설정용 메소드
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chatting");
}
@Override
// MessageBroker를 활용하는 방법 설정
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
}
1. registerStompEndpoints : 이 메소드는 registerWebSocketHandlers 메소드 대신 STOMP 규약을 사용하는 웹소켓 엔드포인트를 구성하는 메소드입니다. 이 메소드를 활용하면 ws://localhost:8080/ws/chat으로 연결된 통신들이 STOMP 규약을 지켜 메세지를 보내도록 설정할 수 있습니다.
2. configureMessageBroker : 이 메소드는 목적지와 상세 엔드포인트를 설정합니다. 이때 enableSimpleBroker() 메소드로 정의된 경로가 클라이언트가 듣기 위한 경로, setApplicationDestinationPrefixes("/app") 이 다음에 정의할 서버 측 엔드포인트에 대한 Prefix를 설정하는 메소드입니다.
WebSocketMapping 클래스 생성
: 이 클래스에서는 STOMP 요청을 받을 엔드포인트를 설정합니다. 이 과정은 RequestMapping을 사용하는 과정과 유사하게
@Controller 어노테이션을 사용합니다.
@Controller
@Slf4j
@RequiredArgsConstructor
public class WebSocketMapping {
// STOMP over WebSocket
private final SimpMessagingTemplate simpMessagingTemplate;
@MessageMapping("/chat")
public void sendChat(
ChatMessage chatMessage,
@Headers Map<String, Object> headers,
@Header("nativeHeaders") Map<String, String> nativeHeaders
) {
log.info(chatMessage.toString());
log.info(headers.toString());
log.info(nativeHeaders.toString());
String time = new SimpleDateFormat("HH:mm").format(new Date());
chatMessage.setTime(time);
simpMessagingTemplate.convertAndSend(
String.format("/topic/%s", chatMessage.getRoomId()),
chatMessage
);
}
}
1. SimpMessaginTemplate: 스프링에서 제공하는 메시징 템플릿으로 실시간 메시징을 구현할 때 유용합니다. Bean 객체로 받아옵니다. 받아오는 이유는 @EnableWebSocketMessageBroker를 설정할 경우 자동으로 설정되는 Bean이며, 연결된 클라이언트에 데이터를 전송하기 위해 사용합니다.
2. @MessageMapping 적용 : @RequestMapping대신 사용, 이때 전달하는 @MessageMapping ("/chat") 인자는 이전에 Configuration 구성 중 작성한 ("/app")뒤에 붙여 명확한 엔드포인트를 가리키는 용도로 활용합니다.
즉, 사용자가 STOMP 클라이언트를 사용하면 ("/app/chat")으로 요청을 보내는 의미입니다.
3. SimpleDateFormat 이용하여 채팅창에 날짜 출력할 수 있는 기능 추가
4. chatMessage.setTime(time); : 사용자가 보낸 시간이 아닌 서버에서 설정 해놓은 시간으로 작동하는 기능
5. simpMessagingTemplate.convertAndSend() : 사용자가 보내준 데이터를 접속하고 있는 사용자들에게 보내주는 용도
하나의 방만 있었던 예시와 다르게 %s(RoomId)를 추가해서 RoomId에 해당하는 채팅방에 보내는 기능 추가
서버에서 클라이언트로 공지 보내기
서버에서 클라이언트에게 admin: "내용" 으로 공지를 내릴 수 있다.
WebSocketMapping 클래스에 @SubscribeMapping 추가하기
// 누군가가 구독할 때 실행하는 메소드
@SubscribeMapping("/topic/{roomId")
public ChatMessage sendGreet(
@DestinationVariable("roomId")
Long roomId
){
log.info("new subscription to {}", roomId);
ChatMessage chatMessage = new ChatMessage();
chatMessage.setRoomId(roomId);
chatMessage.setSender("admin");
chatMessage.setMessage("hello!");
String time = new SimpleDateFormat("HH:mm").format(new Date());
chatMessage.setTime(time);
return chatMessage;
}
}
chat-room.html 수정하기
function connect() {
getRoomName();
const socket = new WebSocket('ws://localhost:8080/chatting');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
console.log('Connected: ' + frame);
stompClient.subscribe(`/app/topic/${roomId}`, function (message) {
receiveMessage(JSON.parse(message.body));
});
});
}
ChatController 수정
이번 실습에서는 방을 구분하기 위해 ChatRoom, ChatRoomEntity, ChatService 등이 구현되어있습니다.
chat-lobby.html을 추가하여 아이디와 입장할 방을 선택하고 채팅이 정상적으로 이뤄지는 실습을 할 수 있습니다.
@Controller
@RequestMapping("chat")
public class ChatController {
@GetMapping
public String index() {
return "chat-lobby";
}
@GetMapping("{roomId}/{userId}")
public String enterRoom(){
return "chat-room";
}
}
chat-lobby.html 코드
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebSocket Chatting</title>
</head>
<body onload="">
<div>
<div>
<label for="nickname">이름: </label><input type="text" id="nickname" placeholder="Choose a nickname"/>
<button id="connect" onclick="loadRooms()">방 찾기</button>
<br><br>
<input type="text" id="room-name" placeholder="Create Room">
<button id="create" onclick="createRoom()">생성</button>
</div>
<div id="room-list">
</div>
</div>
<script type="text/javascript">
async function loadRooms(){
const nickname = document.getElementById("nickname").value ?? 'anonymous';
const chatRooms = await (await fetch("/chat/rooms")).json();
const roomDiv = document.getElementById("room-list");
roomDiv.innerText = ""
chatRooms.forEach((chatRoom) => {
console.log(chatRoom)
const newRoom = document.createElement("div")
newRoom.innerHTML = `<a href="/chat/${chatRoom.id}/${nickname}">${chatRoom.roomName}</a>`;
roomDiv.appendChild(newRoom)
});
}
async function createRoom() {
const nickname = document.getElementById("nickname").value;
const roomName = document.getElementById("room-name").value;
const response = await (await fetch(`/chat/rooms`, {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
roomName
})
})).json();
const roomId = response.id;
window.location.href = `/chat/${roomId}/${nickname}`;
}
</script>
</body>
</html>
서버에서 클라이언트에게 공지 보내기 실습
'Spring' 카테고리의 다른 글
스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 (2) (0) | 2023.07.24 |
---|---|
스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 (1) (0) | 2023.07.23 |
[Spring Security] JWT (0) | 2023.07.10 |
[Spring] Logging (0) | 2023.07.09 |
[Spring] 테스트 클래스란? (0) | 2023.07.09 |