현재 만들고 있는 토이 프로젝트에서 채팅 기능을 구현하고자 하였다.
채팅 기능을 구현하기 위해서는 웹소켓에 대해서 알아야 했다.
처음에 채팅 기능을 구현할 때 HTTP를 이용해서 구현하려고 했다.
하지만 HTTP는 요청과 응답이라는 구조로 통신이 이루어지므로, 실시간으로 바뀌는 정보에 대해서는 지속적으로 요청을 해야되는 불편함이 있다. 이런 식으로 매번 연결을 맺고 끊는 작업은 꽤나 많은 비용이 드는 작업으로 비효율적이다.
http://~
가 아닌 ws://~
로 요청을 보내야 한다.이후 웹소켓으로 연결되고 데이터를 주고받는다.따라서 한 번 연결을 한 후, 클라이언트 간 지속적으로 연결을 유지하는 웹소켓을 이용하기로 결정하였다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
spring-boot-starter-websocket
라이브러리를 추가해준다package com.sunkyuj.douner.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// endpoint 설정 : /api/v1/chat/{postId}
// 이를 통해서 ws://localhost:9090/ws/chat 으로 요청이 들어오면 websocket 통신을 진행한다.
// setAllowedOrigins("*")는 모든 ip에서 접속 가능하도록 해줌
registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
}
}
new WebSocketChatHandler()
와 같이 사용자 정의 핸들러를 직접 넣어도 된다. 어차피 WebSocketHandler를 상속할 것이므로 상관없다./ws/chat
로 설정.ws://주소:포트/ws/chat
로 요청이 들어오면 웹소켓 핸드쉐이킹을 한다.setAllowedOrigins("*")
는 모든 cors 요청을 허용하는 것이다. package com.sunkyuj.douner.chat;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sunkyuj.douner.chat.model.ChatMessageDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/*
* WebSocket Handler 작성
* 소켓 통신은 서버와 클라이언트가 1:n으로 관계를 맺는다. 따라서 한 서버에 여러 클라이언트 접속 가능
* 서버에는 여러 클라이언트가 발송한 메세지를 받아 처리해줄 핸들러가 필요
* TextWebSocketHandler를 상속받아 핸들러 작성
* 클라이언트로 받은 메세지를 log로 출력하고 클라이언트로 환영 메세지를 보내줌
* */
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {
private final ObjectMapper mapper;
// 현재 연결된 세션들
private final Set<WebSocketSession> sessions = new HashSet<>();
// chatRoomId: {session1, session2}
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);
}
// 소켓 통신 시 메세지의 전송을 다루는 부분
@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());
Long chatRoomId = chatMessageDto.getChatRoomId();
// 메모리 상에 채팅방에 대한 세션 없으면 만들어줌
if(!chatRoomSessionMap.containsKey(chatRoomId)){
chatRoomSessionMap.put(chatRoomId,new HashSet<>());
}
Set<WebSocketSession> chatRoomSession = chatRoomSessionMap.get(chatRoomId);
// message 에 담긴 타입을 확인한다.
// 이때 message 에서 getType 으로 가져온 내용이
// ChatDTO 의 열거형인 MessageType 안에 있는 ENTER 과 동일한 값이라면
if (chatMessageDto.getMessageType().equals(ChatMessageDto.MessageType.ENTER)) {
// sessions 에 넘어온 session 을 담고,
chatRoomSession.add(session);
}
if (chatRoomSession.size()>=3) {
removeClosedSession(chatRoomSession);
}
sendMessageToChatRoom(chatMessageDto, chatRoomSession);
}
// 소켓 종료 확인
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// TODO Auto-generated method stub
log.info("{} 연결 끊김", session.getId());
sessions.remove(session);
}
// ====== 채팅 관련 메소드 ======
private void removeClosedSession(Set<WebSocketSession> chatRoomSession) {
chatRoomSession.removeIf(sess -> !sessions.contains(sess));
}
private void sendMessageToChatRoom(ChatMessageDto chatMessageDto, Set<WebSocketSession> chatRoomSession) {
chatRoomSession.parallelStream().forEach(sess -> sendMessage(sess, chatMessageDto));//2
}
public <T> void sendMessage(WebSocketSession session, T message) {
try{
session.sendMessage(new TextMessage(mapper.writeValueAsString(message)));
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
}
sessions
: 현재 연결된 웹소켓 세션들을 담는 Set
afterConnectionEstablished()
, afterConnectionClosed()
에서 반영chatRoomSessionMap
: 채팅방 당 연결된 세션을 담은 Map, Map<roomId, Session Set>
의 형태로 세션을 저장한다.
handleTextMessage()
: 웹소켓 통신 시 메세지 전송을 다루는 부분
TextWebSocketHandler 클래스의 handleTextMessage() 메소드를 Override하여 구현
웹소켓 통신 메세지를 TextMessage로 받고, 이를 mapper
로 파싱하여 ChatMessageDto로 변환
ChatMessageDto
package com.sunkyuj.douner.chat.model;
import lombok.*;
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessageDto {
// 메시지 타입 : 입장, 채팅
public enum MessageType{
ENTER, TALK
}
private MessageType messageType; // 메시지 타입
private Long chatRoomId; // 방 번호
private Long senderId; // 채팅을 보낸 사람
private String message; // 메시지
}
웹소켓 통신 시 보내는 양식
{
"messageType":"TALK", // ENTER, TALK
"chatRoomId":1, // 채팅방 번호
"senderId":100, // 메세지 전송자의 UserId
"message":"hello" // 메세지 내용
}
채팅방 번호를 가져오고, 만약 메모리 상에 채팅방에 대한 세션이 없으면 만들어준다.
메세지 ChatMessageDto의 메세지타입이 ENTER라면 채팅룸 세션에 웹소켓 클라이언트의 세션을 넣어준다.
마지막에 sendMessageToChatRoom(chatMessageDto, chatRoomSession)
호출하여 해당 채팅방에 있는 모든 세션에 메세지 전송
어쨋거나 채팅방에 대한 데이터와 채팅 메세지에 대한 데이터를 DB에 넣어줘야 한다.
DB에 데이터를 넣고, 가져오는 api를 제공하도록 구성하였다.
크롬 확장 프로그램인 Web Socket Client를 사용해서 웹소켓 채팅이 잘 동작하는지 확인해보았다.
POST /api/v1/chat
게시물에 대한 채팅방을 생성ws://localhost:9090/ws/chat
에 HTTP 요청을 하여 핸드쉐이크 -> ws
로 업그레이드https://terianp.tistory.com/142
https://www.youtube.com/watch?v=rvss-_t6gzg
https://everydayyy.tistory.com/87
신