구조
- 채팅방 입장 시 웹소켓 connect~
- 웹소켓 연결 성공 시, 종료 시 프론트 웹소켓 이벤트 핸들러로 채팅방 입·퇴장 메시지 해당 채팅방 유저들에게 message send
- 채팅방 최초 입장 시 REST API 로 해당 채팅방 대화기록 모두 불러와서 화면에 뿌려줌
- 채팅 참여자들이 메시지 보내면 [프론트 message send -> 백 webSocketHandler 데이터 처리]로 DB에 데이터 적재 및 해당 사용자들에세 session message send
#1 스프링 이니셜라이저로 프로젝트 생성
- 채팅방 같이 DB 적재가 자주 일어나야 하는 API 의 경우 NOSQL DB를 사용해야 하지만
- 나는 WebSocket 을 사용하며 개발을 진행한다는 것에 중점을 두어 그냥 MYSQL 을 사용하기로 하였다.
#2 application.yml
spring:
application:
name: websocketchat
# mysql db 설정
datasource:
url: "jdbc:mysql://localhost/websocketchat"
username: "root"
password: ${MYSQL_ROOT_PASSWORD} # 시스템 환경 변수에 저장한 'MYSQL_ROOT_PASSWORD' 사용
driver-class-name : com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: create # 'create', 'update', 'validate', 'none'
properties:
hibernate:
show_sql: trueㄴ
format_sql: true
dialect: org.hibernate.dialect.MySQL8Dialect # MySQL 8.x
#3 MYSQL SCHEMA 또는 DATABASE 생성
- 이거는 어차피 JPA ddl 로 테이블 생성해줄 거기 때문에 미리 생성해주지 않아도 큰 상관은 없음
CREATE DATABASE websocketchat;
USE websocketchat;
CREATE TABLE CHAT(
ID BIGINT AUTO_INCREMENT,
NAME VARCHAR(20) NOT NULL,
DETAIL VARCHAR(255),
ADD_DATE TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CHAT_ROOM_ID VARCHAR(20) NOT NULL,
PRIMARY KEY(id)
)
- 테이블 체크
#4 채팅방 어플리케이션 구현에 필요한 domain, controller, dto, repository 구현
- Chat.java
package com.study.websocketchat.domain.chat;
import com.study.websocketchat.domain.chat.dto.ChatDto;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
@ToString
@Entity
@Getter
@NoArgsConstructor
public class Chat {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "NAME", nullable = false, length = 20)
private String name;
@Column(name = "DETAIL", length = 255)
private String detail;
@Column(name = "ADD_DATE", columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP", updatable = false, insertable = false)
private LocalDateTime addDate;
@Column(name = "CHAT_ROOM_ID", nullable = false, length = 20)
private String chatRoomId;
@Builder
public Chat(Long id, String name, String detail, LocalDateTime addDate, String chatRoomId) {
this.id = id;
this.name = name;
this.detail = detail;
this.addDate = addDate;
this.chatRoomId = chatRoomId;
}
public static Chat dtoToChat(ChatDto chatDto) {
return Chat.builder()
.name(chatDto.getName())
.detail(chatDto.getDetail())
.chatRoomId(chatDto.getChatRoomId())
.build();
}
}
- ChatDto.java
package com.study.websocketchat.domain.chat.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* 단순 테스트 dto 이므로 request 와 response를 구분하지 않겠다.
*/
@ToString
@Getter
@NoArgsConstructor
public class ChatDto {
private String id;
private String name;
private String detail;
private LocalDateTime addDate;
private String chatRoomId;
@Builder
public ChatDto(String id, String name, String detail, LocalDateTime addDate, String chatRoomId) {
this.id = id;
this.name = name;
this.detail = detail;
this.addDate = addDate;
this.chatRoomId = chatRoomId;
}
}
- ChatRepository.java
package com.study.websocketchat.domain.chat.repository;
import com.study.websocketchat.domain.chat.Chat;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ChatRepository extends JpaRepository<Chat, Long> {
// 채팅 리스트 전체 출력
List<Chat> findAll();
@Query("SELECT c.chatRoomId FROM Chat c GROUP BY c.chatRoomId")
List<String> findChatRoomIdList();
// 특정 채팅방의 대화 출력
@Query("SELECT c FROM Chat c WHERE c.chatRoomId = :chatRoomId ORDER BY c.addDate ASC")
List<Chat> findChatDetails(@Param("chatRoomId") String chatRoomId);
}
- ChatController
package com.study.websocketchat.domain.chat;
import com.study.websocketchat.domain.chat.dto.ChatDto;
import com.study.websocketchat.domain.chat.service.ChatService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/chat")
public class ChatController {
private final ChatService chatService;
// 채팅방 최초 입장 시
@PostMapping("/join/chatRoom")
public List<ChatDto> joinChatRoomInit(@RequestBody ChatDto chatDto) {
log.info("ChatController is matchaing : joinChatRoomInit");
log.info(chatService.getDetails(chatDto.getChatRoomId()).toString());
if(chatService.getDetails(chatDto.getChatRoomId()) == null || chatService.getDetails(chatDto.getChatRoomId()).isEmpty()) {
return new ArrayList<>();
} else {
return chatService.getDetails(chatDto.getChatRoomId());
}
}
}
- ChatService.java (인터페이스)
package com.study.websocketchat.domain.chat.service;
import com.study.websocketchat.domain.chat.dto.ChatDto;
import java.util.List;
public interface ChatService {
public List<ChatDto> getDetails(String chatRoomId);
public boolean addChat(ChatDto chatDto);
public List<String> getChatRoomList(); // 안 쓰는 메소드
}
- ChatServiceImpl.java (구현클래스)
package com.study.websocketchat.domain.chat.service;
import com.study.websocketchat.domain.chat.Chat;
import com.study.websocketchat.domain.chat.dto.ChatDto;
import com.study.websocketchat.domain.chat.repository.ChatRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {
private final ChatRepository chatRepository;
/**
* 채팅 기록 없는 경우는 빈 객체 넘겨줌
* @return List<ChatDto>
*/
@Override
public List<ChatDto> getDetails(String chatRoomId) {
try {
// List<ChatDto> chatList = chatRepository.findAll().stream().map(chat -> {
List<ChatDto> chatList = chatRepository.findChatDetails(chatRoomId).stream().map(chat -> {
return ChatDto.builder()
.id(chat.getId().toString())
.name(chat.getName())
.detail(chat.getDetail())
.addDate(chat.getAddDate()).
build();
}).toList();
if(chatList.isEmpty()) {
throw new RuntimeException("chat-record is empty");
}
return chatList;
} catch (Exception e) {
List<ChatDto> emptyChatList = new ArrayList<>();
log.error(e.getLocalizedMessage());
return emptyChatList;
}
}
/**
* 채팅 입력 성공 시 true 실패 시 false 반환
* @param chatDto
* @return boolean
*/
@Override
public boolean addChat(ChatDto chatDto) {
try {
log.info("chatDto -> {}", chatDto);
Chat chat = chatRepository.save(Chat.dtoToChat(chatDto));
log.info("chat -> {}", chat);
if(chat == null) {
throw new RuntimeException("add chat data is failed");
}
return true;
} catch (Exception e) {
log.error(e.getLocalizedMessage());
return false;
}
}
@Override
public List<String> getChatRoomList() { // 안 쓰는 메소드
try {
List<String> chatRoomList = chatRepository.findChatRoomIdList();
if(chatRoomList.isEmpty()) {
throw new RuntimeException("chatRoomList is null");
}
return chatRoomList;
} catch (Exception e) {
List<String> emptyRoomList = new ArrayList<>();
log.error(e.getLocalizedMessage());
return emptyRoomList;
}
}
}
#5 웹소켓 사용을 위한 WebSocketConfig, WebSocketHandlerImpl, runner 클래스에 어노테이션 처리
- WebSocketConfig.java
package com.study.websocketchat.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) {
registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("http://localhost:8080");
}
}
- WebSocketHandlerImpl.java
package com.study.websocketchat.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.study.websocketchat.domain.chat.dto.ChatDto;
import com.study.websocketchat.domain.chat.service.ChatService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.*;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.*;
@Component
@Slf4j
@RequiredArgsConstructor
public class WebSocketHandlerImpl extends TextWebSocketHandler {
private final ObjectMapper objectMapper;
// 채팅 서비스 빈 주입
private final ChatService chatService;
private final Set<WebSocketSession> sessions = new HashSet<>(); // 각 사용자마다 하나의 고유 세션이 그 세션들을 전부 가지고 있는 객체
// chatRoomId: {session1, session2} --> 사용자들의 session 세션이 들어가게 됨 예시는 두 명의 사용자 가정
private final Map<String, Set<WebSocketSession>> chatRoomSessionMap = new HashMap<>();
// 웹소켓 연결 성공 시
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// super.afterConnectionEstablished(session);
log.info("session connect is success by session_id : " + session.getId());
if (!sessions.contains(session)) {
sessions.add(session); // 모든 사용자들의 세션을 모아놓는 sesstions 객체에 신규 사용자 세션 추가
}
}
// # 현재 프로젝트 에서는 다루지 않음
// @Override
// public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
// super.handleMessage(session, message);
// }
// 웹소켓 통신시 메시지 다루는 부분
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// super.handleTextMessage(session, message);
String payLoad = message.getPayload();
log.info("payLoad : " + payLoad);
// 페이로드(입력받은 메시지) -> chatMessageDto로 변환
ChatDto chatDto = objectMapper.readValue(payLoad, ChatDto.class);
log.info("message from session -> {}", chatDto);
String chatRoomId = chatDto.getChatRoomId();
if(!chatRoomSessionMap.containsKey(chatRoomId)) { // 해당 채팅이 채팅세션맵에 존재하지 않는다면
chatRoomSessionMap.put(chatRoomId, new HashSet<>());
}
if(!chatRoomSessionMap.get(chatRoomId).contains(session)) { // 해상 사용자아 해당 채팅방세션맵의 채팅방세션정보에 없다면
chatRoomSessionMap.get(chatRoomId).add(session);
}
Set<WebSocketSession> chatRoomSessions = chatRoomSessionMap.get(chatRoomId);
if (chatRoomSessions != null) {
// 채팅 메시지를 session 에 반환하면서 db 에 적재하는 커스텀 메소드
customSendMessage(chatRoomSessions, chatDto, message);
} else {
log.error("there is no chat Info by chatRoomId : " + chatRoomId);
throw new RuntimeException("채팅방 정보를 찾을 수 없습니다.");
}
}
// # pong 타입 메시지는 다루지 않음 기본 타입인 ping 타입 데이터를 다룸
// @Override
// protected void handlePongMessage(WebSocketSession session, PongMessage message) throws Exception {
// super.handlePongMessage(session, message);
// }
// # 현재 프로젝트에서 사용하지 않으
// @Override
// public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
// // super.handleTransportError(session, exception);
// }
// 웹소켓 연결 종료 시 처리
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// 1. 클라이언트가 채팅방을 떠났을 때 처리해야 할 로직
// 예를 들어, 채팅방 목록에서 사용자를 제거하고 퇴장 메시지 브로드캐스트
// 2. 다른 사용자들에게 사용자가 나갔음을 알릴 수 있음
// 예시: 모든 사용자에게 "사용자가 채팅방을 떠났습니다." 메시지 전송
String targetChatRoomId = null;
Set<WebSocketSession> targetSessions = null;
TextMessage userExitMessage = new TextMessage("{" +
"id:'0'" +
"user:'관리자'" +
"detail:" + "'" + session.getId() + " 님께서 퇴장하였습니다." + "'" +
"user:'알림'" +
"chatRoomId:" + "'" + targetChatRoomId + "'" +
"addTime:" + "'" + new Date().toString() + "'" +
"}");
try {
log.info(session.getId() + "-session is closed");
for (String chatRoomId : chatRoomSessionMap.keySet()) {
if (chatRoomSessionMap.get(chatRoomId).contains(session)) {
targetChatRoomId = chatRoomId;
targetSessions = chatRoomSessionMap.get(chatRoomId);
chatRoomSessionMap.get(chatRoomId).remove(session);
log.info(session.getId() + "-session is deleted in chatRoomId : " + chatRoomId);
}
if(targetSessions == null || targetSessions.isEmpty()) {
chatRoomSessionMap.remove(chatRoomId);
log.info( chatRoomId + "-chatRoom is deleted by this chatRoom user is all out or not exist");
}
}
// 전체 사용자 세션들에서 해당 사용자의 세션 삭제
if (sessions.removeIf(sessionItem -> sessionItem.getId().equals(session.getId()))) {
log.info("user session-id(" + session.getId() + ") id deleted successfully");
}
super.afterConnectionClosed(session, status);
} catch(Exception e) { // 예외 발생 시 웹소켓 서버 강제 종료 되길래 방지하고자 throw 대신 예외 메시지 리턴
// throw new RuntimeException("WebSocket session closing is failed because :" + e.getLocalizedMessage());
customSendError(session, e);
}
}
// 사용하지 않음
// @Override
// public boolean supportsPartialMessages() {
// return super.supportsPartialMessages();
// }
// 채팅 관련 메소드 커스텀 메소드 ======
// 채팅 메시지를 session 에 반환하면서 db 에 적재하는 커스텀 메소드
public void customSendMessage(Set<WebSocketSession> chatRoomSessions, ChatDto chatDto, TextMessage message) {
try{
if(chatDto != null) {
chatService.addChat(chatDto); // db 적재
}
for(WebSocketSession session : chatRoomSessions) {
session.sendMessage(message); // 사용자에게 세션으로 반환
}
} catch (Exception e) {
log.error("send message to chatRoom is faisle because : ");
log.error(e.getLocalizedMessage());
}
}
public void customSendError(WebSocketSession session, Exception e) {
TextMessage errorTextMessage = new TextMessage("{" +
"id:'-1'" +
"user:'관리자'" +
"detail: 메시지 전송에 실패하였습니다. 사유 : " + e.getLocalizedMessage() +
"user:'알림'" +
"addTime:" + "'" + new Date().toString() + "'" +
"}");
log.error(e.getLocalizedMessage());
chatRoomSessionMap.keySet().forEach(key -> {
if (chatRoomSessionMap.get(key).contains(session)) {
customSendMessage(chatRoomSessionMap.get(key), null ,errorTextMessage);
};
});
}
}
#6 어플리케이션 실행을 위한 IndexController(백), index.html(프론트)
- IndexController.java
package com.study.websocketchat.handler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class indexController {
@RequestMapping("/")
public String index() {
return "index"; // 타임리프는 .html 생략가능
}
}
- Index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>채팅방입니다.</title>
</head>
<body>
<script>
let webSocket;
// WebSocket 연결 설정 (채팅방에 입장)
function connectWebSocket() {
let name = "";
if(document.getElementById("name") != null && document.getElementById("name") != undefined) {
name = document.getElementById("name").value.toString().trim();
}
// WebSocket 연결 생성
webSocket = new WebSocket("ws://localhost:8080/ws/chat");
// WebSocket 연결 성공 시
webSocket.onopen = function () {
sendNotification(`'${name}' 님이 채팅방에 입장했습니다.`);
};
// 서버로부터 메시지를 수신했을 때
webSocket.onmessage = function (event) {
const message = event.data;
appendMessage(message);
};
// WebSocket 연결 종료 시
webSocket.onclose = function () {
};
// WebSocket 에러 처리
webSocket.onerror = function (event) {
appendSingleString("에러가 발생했습니다.");
appendMessage(event.data);
};
}
function joinChatRoom () {
const nameElement = document.getElementById("name");
if(nameElement != null && nameElement != undefined) {
if(nameElement.value == null || nameElement.value.toString().trim() === "") {
alert("유저명을 먼저 입력해주세요.");
return;
}
}
connectWebSocket(); // 웹소켓 연결
const joinChatBtn = document.getElementById("joinChatBtn");
let chatRoomId;
if(joinChatBtn) {
chatRoomId = joinChatBtn.value;
console.log("chatRoomId : " + chatRoomId);
}
const body = {
"chatRoomId": chatRoomId
}
// 기존 채팅 기록 불러오기 (비동기 통신)
fetch("chat/join/chatRoom", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(response => response.json())
.then(data => {
appendListMessage(data);
// data.forEach(message => {
// appendMessage(message);
// });
})
.catch(error => {
appendSingleString("채팅 기록을 불러오는 중 오류 발생: " + error);
});
}
function appendSingleString(messageStr) {
// console.log(message);
const chatDiv = document.getElementById("chatDiv");
if(chatDiv == null || chatDiv == undefined) {
return;
}
const br = document.createElement("br");
const messageElement = document.createElement("span");
messageElement.innerText = messageStr;
chatDiv.appendChild(br);
chatDiv.appendChild(messageElement);
chatDiv.scrollTop = chatDiv.scrollHeight; // 스크롤 자동 이동
}
function appendListMessage(messageList) {
const messages = JSON.parse(JSON.stringify(messageList));
const chatDiv = document.getElementById("chatDiv");
if(chatDiv == null || chatDiv == undefined) {
return;
}
if(messages == null || messages.length === 0) {
return;
}
messages.map((message) => {
const targetDate = new Date(message.addDate);
const addDate = targetDate.getFullYear() +"."+ targetDate.getMonth() +"."+ targetDate.getDate() +" "+ targetDate.getHours() +"시"+ targetDate.getMinutes() +"분";
const br = document.createElement("br");
const messageElement = document.createElement("span");
messageElement.innerText = `(${addDate}) ${message.name}: ${message.detail}`;
console.log(messageElement);
chatDiv.appendChild(br);
chatDiv.appendChild(messageElement);
chatDiv.scrollTop = chatDiv.scrollHeight; // 스크롤 자동 이동
})
}
// 메시지를 채팅창에 추가하는 함수
function appendMessage (messageStr, division) {
const message = JSON.parse(messageStr); // 웹소켓 자체에서 넘어온 event.data는 JSON.parse() 만 해줘도 됨
const chatDiv = document.getElementById("chatDiv");
const targetDate = new Date(message.addDate);
const addDate = targetDate.getFullYear() +"."+ targetDate.getMonth() +"."+ targetDate.getDate() +" "+ targetDate.getHours() +"시"+ targetDate.getMinutes() +"분";
if(chatDiv == null || chatDiv == undefined) {
return;
}
const br = document.createElement("br");
const messageElement = document.createElement("span");
messageElement.innerText = `(${addDate}) ${message.name}: ${message.detail}`;
chatDiv.appendChild(br);
chatDiv.appendChild(messageElement);
chatDiv.scrollTop = chatDiv.scrollHeight; // 스크롤 자동 이동
}
// 메시지 전송 함수
function sendMessage () {
const name = document.getElementById("name").value;
const detail = document.getElementById("detail").value;
const chatMessage = {
"name": name,
"detail": detail,
"chatRoomId": document.getElementById("joinChatBtn").value, // 만약 채팅방이 여러개 있는 버전으로 수정한다면 하드코딩 하면 안 됨
"addDate": new Date()
}
if(webSocket == null) {
return;
}
webSocket.send(JSON.stringify(chatMessage)); // 서버로 메시지 전송
// 입력창 초기화
document.getElementById("detail").value = "";
}
function sendNotification (message) {
const notification = {
"name": "알림",
"detail": message,
"chatRoomId": document.getElementById("joinChatBtn").value, // 만약 채팅방이 여러개 있는 버전으로 수정한다면 하드코딩 하면 안 됨
addDate: new Date()
}
if(webSocket == null) {
return;
}
webSocket.send(JSON.stringify(notification)); // 서버로 메시지 전송
}
function exitChatBtn () {
if(webSocket != null) {
if (window.confirm("채팅방을 나가시겠습니까?")) {
if(document.getElementById("name") != null && document.getElementById("name") != undefined) {
const name = document.getElementById("name").value;
sendNotification(`'${name}' 님이 채팅방에서 퇴장했습니다.`);
}
webSocket.close();
}
}
const chatDiv = document.getElementById("chatDiv");
if(chatDiv != null && chatDiv != undefined) {
chatDiv.innerHTML = "";
}
}
</script>
<h1>채팅방 어플리케이션</h1>
<button id="joinChatBtn" value="1" onclick="joinChatRoom()">1번 채팅방 입장</button>
<div id="chatDiv" style="border: 1px solid black; overflow: auto; height: 300px; width: 400px;"></div>
<!-- 메시지 입력 필드 -->
<input type="text" id="name" placeholder="유저명 입력" style="width: 100px;">
<br>
<input type="text" id="detail" placeholder="메시지를 입력하세요" style="width: 400px;">
<button id="addChatBtn" onclick="sendMessage()">보내기</button>
<button id="exitChatBtn" onclick="exitChatBtn()">채팅방 나가기</button>
</body>
</html>
#최종 실행 결과
# 참조
- 백엔드 코드에서는 채팅방이 존재할 때 까지 고려하여 로직을 구현하였으나,
- 프론트 쪽을 채팅방이 여러 개 존재할 때는 고려하여 개발하지 않았다.
- 추후 여유가 되면 한 번 더 커스텀 해보자!~
'API' 카테고리의 다른 글
#2 TEST CODE 작성 : Todo API 단위테스트, 통합테스트 (0) | 2025.01.02 |
---|---|
#1 TEST CODE 작성 : Todo API 만들기 (1) | 2024.12.14 |
WebSocketHandler - 오버라이드 메소드, override method 정리 (0) | 2024.09.16 |
메일 API : 이메일 보내기 API - (구글, 스프링부트) (0) | 2024.05.29 |
Security/OAuth2(구글로그인)/JWT(access, refresh) 발행 및 저장 -개인 복습(API) 1부 (0) | 2024.05.01 |