중고 경매 프로젝트 BidderOwn을 만들기 전에 채팅 기능을 먼저 만들어보고 팀원과 공유하기 위해 작성하였다.
실시간 통신을 하기위해 Socket통신을 통해 client와 서버가 연결을 지속적으로 유지하고 양방향 통신하는 방식이다.
websocket은 HTTP프로토콜과 호환되며 80 Port를 이용하기 때문에 방화벽 제약없이 이용할 수 있다. 접속까지는 HTTP 프로토콜을 이용하고 이후로는 자체적인 Websocket 프로토콜로 통신한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
Websocket을 이용하기 때문에 org.springframework.boot:spring-boot-starter-websocket
의존성을 추가한다.
현재 버전에는 채팅 기록을 데이터베이스에 저장하지 않기 때문에 엔티티가 없다.
import lombok.*;
@Getter @Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ChatMessage {
public enum MessageType {
ENTER, TALK
}
private MessageType messageType;
private String roomId;
private String sender;
private String message;
}
입장과 대화하는 두 가지 타입이 존재하므로 enum타입으로 선언하였다.
현재 참여한 방의 roomId
와 간단하게 보낸 사람과 message내용을 포함하였다.
import lombok.Builder;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
@Getter
public class ChatRoom {
private final String roomId;
private final String name;
private final Set<WebSocketSession> sessions = new HashSet<>();
@Builder
public ChatRoom(String roomId, String name) {
this.roomId = roomId;
this.name = name;
}
public void sendMessage(TextMessage message) {
this.getSessions()
.parallelStream()
.forEach(session -> sendMessageToSession(session, message));
}
private void sendMessageToSession(WebSocketSession session, TextMessage message) {
try {
session.sendMessage(message);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public void join(WebSocketSession session) {
sessions.add(session);
}
public static ChatRoom of(String roomId, String name) {
return ChatRoom.builder()
.roomId(roomId)
.name(name)
.build();
}
}
ChatRoom은 방 이름과 연결된 session(채팅방 유저)을 가지고 있고 이 session에게 메시지를 보낸다.
간단하게 채팅방 정보를 Map에 저장하였다.
package site.leui.chat_example.chat.repository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import site.leui.chat_example.chat.dto.ChatRoom;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@RequiredArgsConstructor
@Repository
public class ChatRepository {
private final Map<String, ChatRoom> chatRooms;
public void save(String roomId, ChatRoom chatRoom) {
chatRooms.put(roomId, chatRoom);
}
public ChatRoom findById(String roomId) {
return chatRooms.get(roomId);
}
public List<ChatRoom> findAll() {
return new ArrayList<>(chatRooms.values());
}
}
package site.leui.chat_example.chat.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import site.leui.chat_example.base.Util;
import site.leui.chat_example.chat.dto.ChatMessage;
import site.leui.chat_example.chat.dto.ChatRoom;
import site.leui.chat_example.chat.repository.ChatRepository;
import java.util.*;
@RequiredArgsConstructor
@Slf4j
@Service
public class ChatService {
private final ChatRepository chatRepository;
public List<ChatRoom> findAll() {
return chatRepository.findAll();
}
public ChatRoom findRoomById(String roomId) {
return chatRepository.findById(roomId);
}
public ChatRoom createRoom(String name) {
String roomId = UUID.randomUUID().toString();
ChatRoom chatRoom = ChatRoom.of(roomId, name);
chatRepository.save(roomId, chatRoom);
return chatRoom;
}
public void handleAction(
String roomId,
WebSocketSession session,
ChatMessage chatMessage
) {
ChatRoom room = findRoomById(roomId);
if (isEnterRoom(chatMessage)) {
room.join(session);
chatMessage.setMessage(chatMessage.getSender() + "님 환영합니다.");
}
TextMessage textMessage = Util.Chat.resolveTextMessage(chatMessage);
room.sendMessage(textMessage);
}
private boolean isEnterRoom(ChatMessage chatMessage) {
return chatMessage.getMessageType().equals(ChatMessage.MessageType.ENTER);
}
}
ChatService는 Room CRUD와 messageType에 따라 메시지를 처리하는 역할을 한다.
WebSocketSession
은 메시지TextMessage
를 연결된 웹소켓으로 보낼 수 있다.
package site.leui.chat_example.chat.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import site.leui.chat_example.chat.dto.ChatRoom;
import site.leui.chat_example.chat.service.ChatService;
import java.util.List;
@RequiredArgsConstructor
@RestController
@RequestMapping("/chat")
public class ChatController {
private final ChatService chatService;
@PostMapping
public ChatRoom createRoom(@RequestParam String name) {
return chatService.createRoom(name);
}
@GetMapping
public List<ChatRoom> getAll() {
return chatService.findAll();
}
}
컨트롤러는 간단하게 방을 생성하고 찾는 역할을 한다.
package site.leui.chat_example.base;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import site.leui.chat_example.chat.dto.ChatMessage;
import site.leui.chat_example.chat.service.ChatService;
@RequiredArgsConstructor
@Component
public class WebSocketChatHandler extends TextWebSocketHandler {
private final ChatService chatService;
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
String payload = message.getPayload();
ChatMessage chatMessage = Util.Chat.resolvePayload(payload);
chatService.handleAction(chatMessage.getRoomId(), session, chatMessage);
}
}
Socket통신은 서버와 클라이언트가 일대다 관계를 맺기 때문에 서버에서 여러 클라이언트가 발송한 메시지를 처리해줄 handler가 필요하다.
클라이언트가 메시지를 보내면 ChatService에서 메시지 타입에 따라 처리한다.
그 후에, 채팅방과 연결된 Session에게 메시지를 전송하게 된다.
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@RequiredArgsConstructor
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketChatHandler webSocketChatHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketChatHandler, "ws/chat").setAllowedOrigins("*");
}
}
WebSocketChatHandler를 이용하여 Websocket을 활성화하기 위해 Config를 생성한다.
@EnableWebSocket
을 선언하여 Websocket을 활성화한다. Websocket에 접속하기 위해 /ws/chat으로 접속한다. 다른 서버에서도 접속할 수 있게 CORS를 모두 허용한다. 이제 클라이언트가 ws://localhost:8080/ws/chat으로 커넥션을 연결하고 메시지를 통신할 수 있다.
Chrome 확장프로그램인 Simple WebSocket Client을 통해 채팅 기능을 테스트할 수 있다.
WebSocketChatHandler에서 설정한 url로 접속을 하면 된다.
WebSocketChatHandler
에서 payload
로 보낼 json데이터를 전송한다.
String payload = message.getPayload();
ChatMessage chatMessage = Util.Chat.resolvePayload(payload);
Util클래스는 ObjectMapper를 이용하여 json을 ChatMessage로 매핑해준다. 이렇게 서버로 메시지가 넘어오면 ENTER 타입이기 때문에 방문 인사를 연결된 모든 session(채팅방 유저)에게 전송한다.
주황색이 클라이언트가 보낸 메시지이고 검은색이 서버에서 연결된 모든 session에게 전송한 메시지이다.
이 확장프로그램을 두개 띄어놓고 채팅이 가능하다.
다음으로는 Stomp로 채팅기능을 고도화 해보자.
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.socket.TextMessage;
import site.leui.chat_example.chat.dto.ChatMessage;
public class Util {
public static class Chat {
private static final ObjectMapper objectMapper = new ObjectMapper();
public static TextMessage resolveTextMessage(ChatMessage message) {
try {
return new TextMessage(objectMapper.writeValueAsString(message));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public static ChatMessage resolvePayload(String payload) {
try {
return objectMapper.readValue(payload, ChatMessage.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
}
아주 유익한 내용이네요!