실시간 채팅방 구현
실시간 채팅을 어떻게 구현할 것인가?
처음에는 이렇게 생각 했다.
채팅 멤버, 채팅룸, 채팅 메세지 이 3개의 테이블 모두가 M:N 관계가 맞을까...?
이렇게 고민을 하다가 자료를 찾아보았다. 실시간 채팅의 테이블 관계 자료들을 찾아보니 대부분의 자료들을 보면 4개의 테이블로 구성되어 있다.
한 개의 채팅룸 멤버 테이블을 별도로 생성 후 유저와 1:N, 채팅룸과 1:N 관계를 갖고 채팅룸 멤버들을 관리하고 나중에 멤버가 가지고 있는 여러 개의 채팅룸을 채팅룸 멤버 테이블에서 조회하여 가져올 수 있다.
'인터넷에서 데이터를 주고 받을 수 있는 프로토콜' 이며 프로토콜은 규약, 정해놓은 규칙으로 규칙을 통해서 개발을 하면 된다.
초기에는 HTML과 같은 하이퍼미디어 문서를 주로 전송했지만 최근에는 Planin text, JSON, XML 등 다양한 형태의 정보도 전송하는 애플리케이션 레이어 프로토콜이다.
또한 웹 브라우저와 웹 서버 간의 커뮤니케이션을 위해 디자인되었지만 최근에는 모바일 애플리케이션 및 IoT 등과의 커뮤니케이션과 같이 다른 목적으로도 사용되고 있다.
Transport Protocol의 일종으로 웹 버전의 TCP 또는 Socket이라고 생각하면 된다.
Websocket은 서버와 클라이언트 간에 Socket Connection을 유지해 실시간으로 양방향 통신이나 데이터 전송을 가능하도록 하는 것이다.
실시간으로 데이터를 주고 받아야하는 실시간 채팅에는 Websocket이 적합하여 간단한 채팅 구현을 해보기로 하였다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.5'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}
tasks.named('test') {
useJUnitPlatform()
}
#Websocket
spring.devtools.livereload.enabled=true
spring.devtools.restart.enabled=false
spring.freemarker.cache=false
spring.jackson.serialization.fail-on-empty-beans=false
@SpringBootApplication
public class ChatApplication {
public static void main(String[] args) {
SpringApplication.run(ChatApplication.class, args);
}
}
@Slf4j
@RequiredArgsConstructor
@Component
public class WebSockChatHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper;
private final ChatService chatService;
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("payload {}", payload);
ChatMessage chatMessage = objectMapper.readValue(payload, ChatMessage.class);
ChatRoom room = chatService.findRoomById(chatMessage.getRoomId());
room.handleActions(session, chatMessage, chatService);
}
}
@RequiredArgsConstructor
@Configuration
@EnableWebSocket
public class WebSockConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
}
}
@Getter
@Setter
public class ChatMessage {
public enum MessageType{
ENTER, TALK
}
private MessageType type;
private String roomId;
private String sender;
private String message;
}
enum을 이용하여 type으로 ENTER(채팅방 입장), TALK(대화하기) 두 가지 상태를 나타내고
채팅방 id(roomId), 메세지를 보낸 사람(sender), 보낸 메세지(message)로 DTO를 생성해준다.
@Getter
public class ChatRoom {
private String roomId;
private String name;
private Set<WebSocketSession> sessions = new HashSet<>();
@Builder
public ChatRoom(String roomId, String name) {
this.roomId = roomId;
this.name = name;
}
public void handlerActions(WebSocketSession session, ChatMessage chatMessage, ChatService chatService) {
if (chatMessage.getType().equals(ChatMessage.MessageType.ENTER)) {
sessions.add(session);
chatMessage.setMessage(chatMessage.getSender() + "님이 입장했습니다.");
}
sendMessage(chatMessage, chatService);
}
private <T> void sendMessage(T message, ChatService chatService) {
sessions.parallelStream()
.forEach(session -> chatService.sendMessage(session, message));
}
}
채팅방에는 입장한 클라이언트의 정보를 가지고 있어야 하기 때문에 WebSocketSession 정보 리스트를 멤버 변수로 선언한다. 그리고 채팅방 id(roomId), 채팅방 이름(name)도 선언한다. 채팅방에 입장하기,대화하기는 handlerActions 메서드에서 ChatMessage 객체에 선언했던 ENTER, TALK 타입으로 분기를 나누어 타입에 맞게 메세지를 보낸다.
@Slf4j
@RequiredArgsConstructor
@Service
public class ChatService {
private final ObjectMapper objectMapper;
private Map<String, ChatRoom> chatRooms;
@PostConstruct
private void init() {
chatRooms = new LinkedHashMap<>();
}
public List<ChatRoom> findAllRoom() {
return new ArrayList<>(chatRooms.values());
}
public ChatRoom findRoomById(String roomId) {
return chatRooms.get(roomId);
}
public ChatRoom createRoom(String name) {
String randomId = UUID.randomUUID().toString();
ChatRoom chatRoom = ChatRoom.builder()
.roomId(randomId)
.name(name)
.build();
chatRooms.put(randomId, chatRoom);
return chatRoom;
}
public <T> void sendMessage(WebSocketSession session, T message) {
try{
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
}
chatRooms를 Map형태로 멤버 변수로 선언하여 RoomId를 key로 갖고 chatRoom을 value로 갖는다. createRoom() 메서드가 실행되면 새로운 채팅방이 생성되고 chatRooms에 chatRoom이 추가된다. 방의 아이디는 UUID로 랜덤으로 생성하여 지정되며, roomId를 사용해 findRoomById() 메서드를 통해서 해당 채팅방을 불러올 수 있다.
sendMessage() 메서드는 TALK 상태일 경우 실행되는 메서드로, 메시지를 해당 채팅방의 webSocket 세션에 보내는 메서드이다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/chat")
public class ChatController {
private final ChatService chatService;
@PostMapping
public ChatRoom createRoom(@RequestBody String name) {
return chatService.createRoom(name);
}
@GetMapping
public List<ChatRoom> findAllRoom() {
return chatService.findAllRoom();
}
}
@RequestMapping 어노테이션을 통해서 “/chat” 주소로 Post요청이 들어오면 json 데이터에서 name값을 받아 해당 이름으로 된 채팅방을 생성하고, Get요청이 들어오면 현재 열려있는 모든 채팅방을 모두 조회 할 수 있게 해주었다.
테스트
해당 URL에서 채팅방을 생성하면 roomId를 결과 값으로 return하는 것을 확인 할 수 있다. 해당 roomId를 가지고 구글 확장 프로그램 WebSocketTestClient에서 채팅방 입장을 해본다.
Websocket연결할 때는 ws로 시작하고WebsocketConfig 클래스에서 ws/chat으로 URL을 설정해줬기 때문에 "ws://locahost:8080/ws/chat" 으로 URL을 Open 해주면 Status: OPENED으로 변경된 것을 확인할 수 있다.
Request에 JSON형태의 데이터를 보낸다.
{
"type" : "ENTER",
"roomId" : "6daba994-0ffe-4b42-a1c7-616b81d49298",
"sender" : "사용자_01",
"message" : ""
}
ENTER type으로 연결된 것을 확인할 수 있다. 같은 방식으로 사용자_02, 사용자_03도 채팅방에 참여를 해본다.
JSON형태의 Request에서 type을 TALK로 변경하고 message에 보낼 메세지를 입력해서 보내본다.
지금까지는 Websocket을 이용한 간단한 실시간채팅을 구현해보았다. 이제는
참고
https://surprisecomputer.tistory.com/54
https://duckdevelope.tistory.com/19
https://appmaster.io/ko/blog/websocketiran-mueosimyeo-eoddeohge-saengseonghabnigga
https://javaengine.tistory.com/entry/Spring-websocket-chatting-server1-%E2%80%93-basic-websocket-server
https://learnote-dev.com/java/Spring-%EA%B2%8C%EC%8B%9C%ED%8C%90-API-%EB%A7%8C%EB%93%A4%EA%B8%B0-webSocket%EC%9C%BC%EB%A1%9C-%EC%B1%84%ED%8C%85%EC%84%9C%EB%B2%84-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0/