[Spring Boot] WebSocket 실시간 채팅 간단하게 구현하기!
개요
채팅 기능을 구현하기 위해 WebSocket 에 대한 공부를 시작했다. 웹소켓을 왜 사용하는 지, 동작부터 구현까지 정리해보도록 하겠다.
HTTP vs WebSocket
가장 일반적으로 서버와의 통신은 HTTP 를 통해 이루어진다. 하지만 이 경우 서버는 요청이 오지 않으면 응답을 줄 수 없는 치명적인 단점이 존재한다. 다시말해 채팅 혹은 주식 가격 등 실시간성으로 변하는 데이터를 클라이언트가 확인하기 위해서는 계속해서 HTTP 요청을 보내고 받아야한다.
당연하게도 클라이언트가 매번 똑같은 요청을 보내고 있는 것은 비효율적이다. 이를 위해 2 가지 해결책이 존재한다.
- Server-Sent Evnet
- WebSocket
SSE(Server-Sent Event) 는 단방향 데이터 통신이다. HTTP 프로토콜을 사용하며, 클라이언트는 데이터 수신만 가능하다.
반면 WebSocket 은 양방향으로 데이터를 주고 받을 수 있다는 장점이 있다.
HTTP 와 웹소켓의 차이가 정리되어있다. 가장 큰 차이는 웹소켓이 연결을 유지한다는 점이다.
HTTP 와 웹소켓은 주고받는 데이터에서도 차이가 존재한다.
HTTP 는 매번 HTTP 요청을 주고받기에 헤더 등 모든 정보를 계속해서 주고받는다.
반면 웹소켓은 처음 핸드쉐이크를 통해 연결을 생성하면 이후 필요한 메세지만 주고받기에 주고받는 데이터의 양에서 차이가 많이 난다.
웹소켓 구현
1. WebSocketConfig
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry
// /ws/conn 경로로 WebSocket 연결을 허용
.addHandler(webSocketHandler, "/ws/conn")
// CORS 허용
.setAllowedOrigins("*");
}
}
Spring 에서는 간단하게 WebSocketConfig 를 구성할 수 있다.
-
.addHandler(webSocketHandler, “/ws/chat”)
- webSocketHandler 핸들러를 사용한다.
- endpoint = “/ws/conn” 으로 설정한다.
- ws://localhost:8080/ws/conn 으로 웹소켓 연결이 가능하다.
-
.setAllowedOrigins(“*”)
- CORS 허용 설정이다.
2. WebSocketHandler
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {
private final ObjectMapper mapper;
// 소켓 세션을 저장할 Set
private final Set<WebSocketSession> sessions = new HashSet<>();
// 소켓 연결 확인
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// TODO Auto-generated method stub
log.info("{} 연결됨", session.getId());
sessions.add(session);
session.sendMessage(new TextMessage("WebSocket 연결 완료"));
}
// 소켓 메세지 처리
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("payload {}", payload);
for (WebSocketSession s : sessions){
s.sendMessage(new TextMessage(payload));
}
}
// 소켓 연결 종료
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// TODO Auto-generated method stub
log.info("{} 연결 끊김", session.getId());
sessions.remove(session);
session.sendMessage(new TextMessage("WebSocket 연결 종료"));
}
}
TextWebSocketHandler 를 상속받으며 연결, 메세지 처리, 연결 종료 3가지 메서드를 오버라이딩해야한다. 소켓 통신은 서버와 클라이언트가 1:N 으로 연결을 맺을 수 있다. 또한 여기서의 세션은 흔히 알고 있는 세션과 달리 WebSocket 의 연결 정보를 담고 있는 객체라 보면 된다.
- afterConnectionEstablished
- 소켓의 연결부분을 처리하며 소켓 세션에 현재 세션을 추가한다.
- handleTextMessage
- 실제 메세지를 처리하는 부분이다.
- payload 를 받아와 sessions 에 저장된 세션에 모두 sendMessage 를 통해 전달한다.
- afterConnectionClosed
- 연결 해제가 요청될 경우 sessions 에서 해당 세션을 제거한다.
APIC 툴을 사용해 웹소켓 통신을 테스트해보면 실제 잘 동작하는 것을 확인할 수 있다.
하지만 이런 채팅은 웹소켓 연결을 가진 사용자 모두에게 메세지를 계속해서 뿌리기 때문에 1:1 채팅을 구현할 수 없다.
채팅방 구현
1. Message DTO
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessageDto {
// 메시지 타입 : 입장, 채팅, 퇴장
public enum MessageType{
JOIN, TALK, LEAVE
}
private MessageType messageType; // 메시지 타입
private Long chatRoomId; // 방번호
private String message; // 메시지
}
우선 메세지 DTO 를 생성한다.
채팅방을 유지하고 DTO 를 통해 입장, 채팅, 퇴장을 모두 관리할 것이기에 MessageType 을 위와 같이 설정한다.
메세지를 보낼 때에는 방 번호와 보낼 메세지도 필요하다.
2. WebSocketHandler
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {
private final ObjectMapper mapper;
// 소켓 세션을 저장할 Set
private final Set<WebSocketSession> sessions = new HashSet<>();
// 채팅방 id와 소켓 세션을 저장할 Map
private final Map<Long,Set<WebSocketSession>> chatRoomSessionMap = new HashMap<>();
// 소켓 연결 확인
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// TODO Auto-generated method stub
log.info("{} 연결됨", session.getId());
sessions.add(session);
session.sendMessage(new TextMessage("WebSocket 연결 완료"));
}
// 소켓 메세지 처리
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("payload {}", payload);
// 클라이언트로부터 받은 메세지를 ChatMessageDto로 변환
ChatMessageDto chatMessageDto = mapper.readValue(payload, ChatMessageDto.class);
log.info("session {}", chatMessageDto.toString());
// 메세지 타입에 따라 분기
if(chatMessageDto.getMessageType().equals(ChatMessageDto.MessageType.JOIN)){
// 입장 메세지
chatRoomSessionMap.computeIfAbsent(chatMessageDto.getChatRoomId(), s -> new HashSet<>()).add(session);
chatMessageDto.setMessage("님이 입장하셨습니다.");
}
else if(chatMessageDto.getMessageType().equals(ChatMessageDto.MessageType.LEAVE)){
// 퇴장 메세지
chatRoomSessionMap.get(chatMessageDto.getChatRoomId()).remove(session);
chatMessageDto.setMessage("님이 퇴장하셨습니다.");
}
// 채팅 메세지 전송
for(WebSocketSession webSocketSession : chatRoomSessionMap.get(chatMessageDto.getChatRoomId())){
webSocketSession.sendMessage(new TextMessage(mapper.writeValueAsString(chatMessageDto)));
}
}
// 소켓 연결 종료
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// TODO Auto-generated method stub
log.info("{} 연결 끊김", session.getId());
sessions.remove(session);
session.sendMessage(new TextMessage("WebSocket 연결 종료"));
}
}
추가된 코드는 채팅방 id와 소켓 세션을 저장할 Map 이다.
여기에 채팅방 별로 연결되어있는 세션이 저장될 것이다.
- handleTextMessage
- 클라이언트로부터 받은 메세지를 ChatMessageDto로 변환한다.
- 메세지 타입에 따라 분기한다.
- JOIN 일 경우 채팅방이 존재하면 세션을 추가하고 존재하지 않으면 새로 만들어 추가한다.
- LEAVE 일 경우 채팅방에서 세션을 삭제한다.
- 이후 채팅 메세지를 채팅방에 속한 세션들에게만 전송한다.
위 과정을 통해 채팅방 입장, 대화, 퇴장을 간단하게 구현하였다.
사용자 A, B 는 1번 채팅방, 사용자 C 는 2번 채팅방에 입장한 상황이다.
1번 채팅방에 들어간 사용자 A 가 대화 메세지를 전송했다. 2번 채팅방에 들어가있는 사용자 C 는 메세지를 못받는 상황이다.
1번 채팅방에 들어간 사용자 B 가 퇴장하는 상황이다. A 에게 퇴장 메세지가 잘 전달되었다.
사용자 B 가 2번 채팅방에 입장하였다. 사용자 C 에게 메세지가 잘 전달된다.
사용자 B 가 1번 채팅방에도 입장하여 1, 2 번 채팅방에 존재하는 상황이다.
사용자 B 가 2번 채팅방에 대화를 전송한다. DTO 에 방번호가 존재하기에 해당 B 는 1, 2 번 방에 속하더라도 2번만 특정해서 메세지를 보낼 수 있다.
결론
아주 간단하게 Websocket 구현이 가능하다. 하지만 여기서도 계속해서 메세지 이외에 함께 보내는 데이터가 꽤 있다. 이는 STOMP 를 통해 해결 가능하다. 다음에는 STOMP 를 알아보도록 하자.
댓글남기기