최근 진행중인 프로젝트 내에서 웹 소켓을 이용하여 채팅 기능을 구현할 일이 생겨 웹소켓에 대한 내용을 정리해보려고 한다.
하나의 TPC 접속에 전이중(full duplex) 통신 채널을 제공하는 컴퓨터 통신 프로토콜을 말한다.
여기서 전이중 통신이란 동시에 양방향 전송이 가능한 방식을 말한다.
별도의 요청 없이 데이터를 수신할 수 있기 때문에 클라이언트와 서버간의 실시간 통신이 가능하다. 또한 다양한 웹 브라우저와 플랫폼에서 호환이 가능하다. 이러한 이유로 채팅과 같은 메세지 송수신에 유용하다.
지금부터는 웹 소켓을 통해 spring framework로 구성한 백엔드 서버에 채팅 기능을 구현해보려고 한다.
전체적인 흐름은 채팅방 생성 -> 웹소켓 연결 -> 웹소켓 메세지 전송 -> 웹소켓 연결 종료이다.
java 17, gradle, spring boot를 활용하여 개발을 진행하였다.
우선 웹 소켓을 활용하기 위한 라이브러리를 추가해준다.
build.gradle - 웹 소켓 관련 dependency 추가
implementation 'org.springframework.boot:spring-boot-starter-websocket'
https://github.com/Broomii/broomii-backend/tree/main/src/main/java/Boormii/soonDelivery/chat
아래에 작성된 내용에 부족한 부분도 있고 개선된 내용이 반영되어 있지 않은 부분도 있어 완성된 전체 코드를 확인하기를 원하시는 분은 전체 코드를 확인해주세요.
WebSocketConfig 클래스
@RequiredArgsConstructor
@Configuration
@EnableWebSocket
public class WebSockConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// ws/chat 경로를 통해 들어오는 웹 소켓 통신 요청에 대한 처리를 위한 Handler 추가
registry.addHandler(webSocketHandler, "ws/chat").setAllowedOrigins("*");
}
}
WebSocketConfig 클래스이다.
WebSocket을 통한 요청을 처리해주는 WebSocketHandler에 채팅에 대한 요청을 받을 ws/chat 경로에 대한 작업을 처리해주도록 등록하였다.
채팅방 생성
ChatController
@PostMapping
public CommonResponse<Object> createChattingRoom(@RequestBody CreateChattingRoomRequestDto createChattingRoomRequestDto, HttpServletRequest http) {
return responseService.getSuccessResponse("채팅방 생성 성공", chatService.createRoom(createChattingRoomRequestDto, jwtUtils.getEmailFromRequestHeader(http)));
}
채팅방 생성 요청의 경우 채팅과 별개로 웹 소켓이 아닌 rest api를 통해 요청을 받아 진행한다.
채팅방 생성을 위한 정보를 postMapping을 통해 넘겹다고 service 패키지의 로직으로 넘겨준다.
ChatService의 createRoom
private Map<Long, ChatRoom> chatRooms;
@Transactional
public ChatRoom createRoom(CreateChattingRoomRequestDto createChattingRoomRequestDto, String email) {
Orders orders = ordersRepository.findById(createChattingRoomRequestDto.getOrderId()).get();
Members deliveryMan = membersRepository.findByEmail(email).get();
ChattingRoom chattingRoom = new ChattingRoom(orders, deliveryMan); // 1
deliveryMan.addChattingRoom(chattingRoom);
orders.addChattingRoom(chattingRoom);
chattingRoomRepository.save(chattingRoom); // 2
ChatRoom chatRoom = ChatRoom.builder() // 3
.id(chattingRoom.getId())
.name(deliveryMan.getNickName())
.build();
chatRooms.put(chatRoom.getId(), chatRoom); // 4
return chatRoom;
}
채팅방 생성을 담당하는 메서드이다.
상품 주문에 대한 채팅방을 생성하는 서비스이기 때문에 주문과 관련된 로직이 있지만 채팅 기능에 대한 글이기 때문에 필요 로직만 설명하려고 한다.
WebSocketHandler 클래스
@RequiredArgsConstructor
@Component
public class WebSocketHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper;
private final ChatService chatService;
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
ChatMessageDto chatMessageDto = objectMapper.readValue(payload, ChatMessageDto.class);
ChatRoom chatRoom = chatService.findRoomById(chatMessageDto.getRoomId());
chatRoom.handlerActions(session, chatMessageDto, chatService);
}
@Override
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status){
ChatRoom.deleteSession(webSocketSession);
}
}
웹 소켓을 통한 요청을 ChatRoom으로 전달해주는 클래스이다.
웹 소켓 연결에 대한 처리는 부모 클래스인 TextWebSocketHandler의 메서드를 수정할 부분이 없기 때문에 필요 부분만 오버라이딩하여 구현하였다.
handleTextMessage의 경우 웹 소켓을 통해 전송되는 메세지에 대한 처리를 담당한다.
전송된 메세지를 파라미터로 전달받고 String 타입의 변수에 할당한다.
현재 String 타입의 메세지가 전달되었지만 원활한 처리를 위해 objectMapper를 활용해 미리 구현해둔 Dto의 타입으로 변경하였다.
메세지로 전송받은 채팅방의 아이디를 통해 채팅방을 찾고 ChatRoom의 메서드인 handlerActions에 넘겨준다.
afterConnectionClosed 메서드의 경우 메서드가 종료될 경우 해당 소켓 통신에 대한 세션을 삭제시켜주는 메서드로 세션을 파라미터로 전달받아 ChatRoom의 삭제 메서드로 넘겨준다.
ChatRoom 클래스
public class ChatRoom {
private Long id;
private String name;
private static Set<WebSocketSession> sessions = new HashSet<>();
@Builder
public ChatRoom(Long id, String name) {
this.id = id;
this.name = name;
}
public void handlerActions(WebSocketSession session, ChatMessageDto chatMessageDto, ChatService chatService) {
if (chatMessageDto.getType().equals(ChatMessageDto.MessageType.ENTER)) {
sessions.add(session);
}
chatService.saveMessage(chatMessageDto);
sendMessage(chatMessageDto, chatService);
}
private <T> void sendMessage(T message, ChatService chatService) {
sessions.parallelStream()
.forEach(session -> chatService.sendMessage(session, message));
}
public static void deleteSession(WebSocketSession webSocketSession){
sessions.remove(webSocketSession);
}
}
앞서 WebSocketHandler에서 전달받은 요청에 대한 직접적인 처리를 담당한다.
handlerActions메서드는 메시지 전송에 대한 요청을 처리한다.
message의 타입을 확인하여 채팅방의 입장하는 경우인 enter type에 대한 요청인 경우 세션을 담는 HashSet 변수에 해당 세션을 추가한다.
chatService의 saveMessage 메서드를 통해 메세지를 데이터베이스에 저장해준다.
이후 sendMessage 메서드를 통해 메세지 전송을 실행한다.
sendMessage 메서드의 경우 앞선 메서드에서 넘겨받은 메세지를 전송해주는 메서드로 세션이 담겨있는 sessions 변수를 탐색하며 모든 참가자에게 메세지를 전송해준다.
deleteSession 메서드는 웹 소켓 연결 종료에 대한 처리를 진행하는 메서드로 sessions에서 해당 세션을 삭제해준다.
ChatService 클래스
@Transactional
public void saveMessage(ChatMessageDto chatMessageDto) {
String email = jwtUtils.getEmailFromToken(chatMessageDto.getToken());
Members members = membersRepository.findByEmail(email).get();
ChattingRoom chattingRoom = chattingRoomRepository.findById(chatMessageDto.getRoomId());
ChattingMessage chattingMessage = new ChattingMessage(chatMessageDto, chattingRoom, members);
chattingMessageRepository.save(chattingMessage);
members.addChattingMessage(chattingMessage);
chattingRoom.addChattingMessage(chattingMessage);
}
public <T> void sendMessage(WebSocketSession session, T message) {
try {
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
} catch (IOException e) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "입출력 오류");
}
}
ChatService 클래스의 saveMessasge, sendMessage 메서드이다.
saveMessage 메서드는 웹 소켓 통신 로직과 별개로 채팅 메세지를 데이터베이스에 저장하기 위한 로직을 담당하는 메서드이다.
넘겨받은 토큰을 통해 해당 유저에 대한 정보를 읽고 넘겨받은 채팅방의 id를 통해 채팅방에 대한 객체를 불러온다.
이후 읽어온 정보를 통해 채팅 메세지에 대한 변수를 생성한 뒤 데이터베이스에 저장한다.
sendMessage 메서드는 메세지의 전송을 담당한다.
넘겨받은 메세지를 objectMapper를 통해 타입을 변경한 뒤 TextMessage 객체를 생성하고 session 내부 메서드인 sendMessage를 활용하여 메세지의 전송을 진행한다.
채팅방 생성
웹 소켓 통신 확인
웹 소켓 연결을 종료하는 과정에서 해당 연결과 연관된 세션을 삭제하고자 하는 로직에서 종료 시점에 백엔드 서버에 채팅방에 대한 정보를 전달하는 방법을 찾지 못하였고 채팅방 정보 없이 세션을 삭제하기 위해 session 변수를 static으로 선언하였다.
session이 채팅방마다 생성되는게 아니라 전역으로 생성되었기 때문에 채팅방 하나에 대해서만 정상적으로 돌아간다.
또한 회원 정보를 알기 위해 toekn을 메세지 바디에 담아서 전송하였는데 더 깔끔한 방식으로 전달할 수 있도록 찾아보려고 한다.
채팅방이 전역으로 생성되는 문제를 해결하였고 위에 적어놓은 링크의 전체 코드를 통해 정상적으로 동작하는 코드를 확인할 수 있다.