요구사항
- 채팅방에서 채팅한 내역들에 대해서 저장하고 불러오기
- 한사람이 읽었던 위치 저장하고 불러오기
개요
- 개발환경 및 채팅방 환경은 아래와 같습니다.
[팀프로젝트] SockJs를 활용한 STOMP 방식 채팅방에서 실시간 사용자 체크하는 기능 구현하기
요구 사항 채팅방 사용자가 실시간으로 접속한 유저를 알 수 있게 구현 입장 또는 활동 시 유저목록 내 UI 초록색 퇴장 시 유저목록 내 UI 빨간색 새로 가입 시 리스트 추가 개요 개발환경 에디터
koopi.tistory.com
ERD
여러 사용자가 참여한 채팅방에서 채팅 내역을 어떻게 저장할 수 있을까 라는 고민에 엔티티와의 관계를 그려보았다.


고안한 ERD 로 하여금 엔티티를 구성하였으며, 참여 관련된 자료는 이전 포스팅을 참고하시기 바랍니다.
하나의 모임이 만들어지면 채팅방이 자동으로 생성된다.
( 개설 1:1 관계 > 모임과 채팅방의 관계에서 모임 엔티티에는 참조객체가 있으나 채팅방엔티티에는 별도로 구현하지 않음 > 추가 필요 )
하나의 채팅방을 만든 사람은 하나의 작성자이다
( 작성자 N:1 관계로 설계함 > 1:1 또는 관계를 없애는게 맞을거같다는 생각이 듬 )
하나의 채팅방에서 채팅을 입력하면 N개의 채팅이 저장된다
( 저장 1:N 관계 )
단체 채팅방에서 사용자 개개인에 대한 읽었던 위치를 저장하는 방법을 생각하다가
데이터베이스에 사용자 정보, 채팅방 정보, 시간 이렇게 컬럼이 들어가서 확인하는 방안을 생각 해 보았다.
아래처럼 ERD를 설계했고 개발을 진행했다.


엔티티 설계

- 채팅방에 대한 엔티티다.
- 하나의 모임이 만들어지면 하나의 채팅방이 생성이 된다.
- 채팅방은 작성자인 사용자ID 를 FK 로 가진다.

- 채팅에 대한 엔티티다.
- 채팅이 이루어질때마다 내역들이 저장된다
- 채팅은 어디서 채팅이 이루어지는지 채팅방ID 를 FK로 가진다.

- 읽음에 대한 엔티티다.
- 채팅방 입장 시 시간을 불러와 언제까지 읽었는지 체크
- 채팅방 퇴장 시 시간을 수정 또는 생성하여 언제까지 읽었는지 저장한다
- 채팅방ID 와 사용자ID를 FK 로 하여 값을 찾는다.
진행사항
시연사진




플로우차트

- 채팅 전송에는 전송 시 해당 내용을 DB 에 저장한다.
- 채팅방 입장 시 저장되었던 채팅 기록을 불러온다.
- 입장할때 로그인 했던 사용자의 정보와 해당 채팅방의 정보를 통해 최근 읽었던 시간을 찾아 해당 위치로 스크롤링한다.
- 퇴장 전에 현재 시간에 대해서 기록처리를 한다.
세부 로직 및 코드
위에서 부터 하나씩 진행하겠습니다.
채팅 내역 저장
채팅 전송에는 전송 시 해당 내용을 DB 에 저장한다.
$(document).keyup(function (event) {
var userName = document.getElementById("myName").innerHTML;
var msg = document.getElementById("msg");
if (event.keyCode === 13) {
if (msg.value.trim() == '') {
console.log("공백");
return;
}
fetcher();
//현재 입력한 내용에 대해서 저장하는 메소드
stomp.send('/pub/chat/message', {}, JSON.stringify({roomId: roomId, message: msg.value, writer: userName}));
msg.value = '';
}
});

엔터키를 눌렀을때 동작하는 메소드인데, 중요하게 볼것은 fetcher() 이다.
async function fetcher() {
console.log("fetcher");
let response = await fetch("/api/v1/chat", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
credentials: "include",
body: JSON.stringify({
writer: document.getElementById("myName").innerHTML,
message: document.getElementById("msg").value,
roomId: roomId
})
})
console.log("end");
console.log(response);
$('#messagearea').scrollTop($('#messagearea')[0].scrollHeight);
console.log("fetcher end");
}
fetcher() 는 사용자 정의 함수로, fetch 방식으로 API를 호출해 [ POST : /api/v1/chat ]
내가 입력한 메세지를 저장한다.
//ChatController.java
// "/api/v1/chat"
@PostMapping()
public void addChat(@RequestBody ChatMessageDTO chatMessageDTO){
log.info(chatMessageDTO.getMessage()+" "+chatMessageDTO.getWriter());
chatService.addChat(chatMessageDTO);
}
fetch 함수에서 사용된 API 이며, DTO 로 작성자, 메세지, 채팅방ID 를 받고 있다.
@Transactional
public Response addChat(ChatMessageDTO chatMessageDTO){
ChatRoom chatRoom = chatRoomRepository.findByRoomId(chatMessageDTO.getRoomId()).orElseThrow(()->new AppException(ErrorCode.DB_ERROR,""));
Chat chater = chatRepository.save(chatMessageDTO.toChat(chatRoom));
return Response.success(chater);
}
서비스단에서는 채팅방ID로 채팅방을 찾고, Chat 엔티티를 만들어 Repository에 저장하게 된다.

이렇게 입력한 채팅에 대해서 저장하게 되는 모습을 확인할 수 있음
이제 채팅방에 접속하게되면 DB 에 저장되어있는 채팅 내역들을 불러오면 된다.
채팅 내역 불러오기
채팅방 입장 시 저장되었던 채팅 기록을 불러온다.
async function loadFetcher() {
console.log("loadFetcher");
let response = await fetch("/api/v1/chat/" + roomId, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: 'include'
})
if (response.ok) {
let json = await response.json();
console.log(json);
for (var i = 0; i < json.result.length; i++) {
if (json.result[i].writer === document.getElementById("myName").innerHTML) {
str = "<div class='chatbox__messages__user-message'>";
str += "<div style='float: right' class='chatbox__messages__user-message--ind-message'>";
str += "<p style='color: #6c757d; size: 3em' >" + json.result[i].createdAt + "</p>";
str += "<p className=\"name\">" + json.result[i].writer + "</p>";
str += "<br/>"
str += "<span className=\"message\">" + json.result[i].message + "</span>";
str += "</div></div>";
$("#messagearea").append(str);
} else {
str = "<div class='chatbox__messages__user-message'>";
str += "<div style='float: left' class='chatbox__messages__user-message--ind-message'>";
str += "<p style='color: #6c757d; size: 3em' >" + json.result[i].createdAt + "</p>";
str += "<p className=\"name\">" + json.result[i].writer + "</p>";
str += "<br/>"
str += "<span className=\"message\">" + json.result[i].message + "</span>";
str += "</div></div>";
$("#messagearea").append(str);
}
}
readFetch();
} else {
let json = await response.json();
console.log(json.result.message);
}
console.log("loadFetcher END");
}
loadFetcher() 는 사용자 정의 함수로, fetch GET 방식으로 api/v1/chat/(roomId) 호출해 데이터 값을 불러오는 JS 스크립트
//ChatController.java
@GetMapping("/{roomId}")
public Response listChat(@PathVariable Long roomId){
log.info("roomId : " + roomId);
List<ChatMessageDTO> list = chatService.listChat(roomId);
return Response.success(list);
}
마찬가지 컨트롤러로 "/api/v1/chat/(roomId)" 매핑되어 로직이 처리된다.
public List<ChatMessageDTO> listChat(Long id){
ChatRoom chatRoom = chatRoomRepository.findByRoomId(id).orElseThrow(()->new AppException(ErrorCode.DB_ERROR,""));
List<Chat> chats = chatRepository.findByChatRoom(chatRoom);
List<ChatMessageDTO> list = new ArrayList<>();
for(Chat chat : chats){
ChatMessageDTO chatMessageDTO = ChatMessageDTO.builder().message(chat.getMessage())
.writer(chat.getWriter())
.createdAt(chat.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
.build();
list.add(chatMessageDTO);
}
return list;
}
해당 채팅방 ID 값을 불러와 FK 값으로 Chat Repository 에서 검색하며 채팅내역들을 리스트에 저장해 반환한다.
아까 DB 저장된 그림을 참고해서 이해하면 좋을 것이다.
이렇게 해서 채팅방에 접속하게 되면, 내역들을 불러 올 수 있게 된다.
읽은 위치 인덱싱
입장할때 로그인 했던 사용자의 정보와 해당 채팅방의 정보를 통해 최근 읽었던 시간을 찾아 해당 위치로 스크롤링한다.
사용자 정보와 채팅방 정보가 읽음으로 관계를 처리했기 때문에 읽음 엔티티는 다음과 같이 저장된다.

어떤 채팅방에서 (roomID) 누가 (userID) 언제 나갔는지 (config_time) 에 대한 정보를 관리하게 된다.
따라서 채팅방에 접속하였을때, 해당 roomID 값과 userID 를 FK 로 해서 본인이 읽었던 시간을 확인할 수 있게 된다.
async function readFetch() {
console.log("readFetch");
var userName = document.getElementById("myName").innerHTML;
let response = await fetch("/api/v1/chat/check/" + roomId + "/" + userName, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
})
if (response.ok) {
let json = await response.json();
var index = json.result.index;
if (index === 0) {
document.getElementsByClassName("chatbox__messages__user-message--ind-message").item(index).innerHTML =
"<p id='thisisfo' style='color: #09f3a7; size: 3em' > 처음이시군요! </p>" + document.getElementsByClassName("chatbox__messages__user-message--ind-message").item(index).innerHTML;
} else {
index = index - 1;
document.getElementsByClassName("chatbox__messages__user-message--ind-message").item(index).innerHTML =
"<p id='thisisfo' style='color: #09f3a7; size: 3em' > 여기까지 읽었어요! </p>" + document.getElementsByClassName("chatbox__messages__user-message--ind-message").item(index).innerHTML;
line = index;
}
var viewpoint = document.getElementById('thisisfo');
viewpoint.scrollIntoView();
}
console.log("readFetch END");
}
채팅내역이 불러오기가 끝나고 읽은 위치로 스크롤링 해주는 사용자 정의 메소드이다.
fetch GET 방식 "/api/v1/chat/check/(roomId)/(userName)" 을 호출하여 본인이 읽었던 시간을 가져온다.
//ChatController.java
@GetMapping("/check/{roomId}/{userName}")
public Response checkRead(@PathVariable Long roomId, @PathVariable String userName){
return checkService.checkRead(roomId,userName);
}
컨트롤러는 다음과 같이 구성하였고
public Response checkRead(Long roomId, String userName){
ChatRoom chatRoom = chatRoomRepository.findByRoomId(roomId).orElseThrow(()->new AppException(ErrorCode.DB_ERROR,ErrorCode.DB_ERROR.getMessage()));
User user = userRepository.findByUserName(userName).orElseThrow(()->new AppException(ErrorCode.USERID_NOT_FOUND,ErrorCode.USERID_NOT_FOUND.getMessage()));
int index = 0;
if(chatConfigRepository.existsByUserAndChatRoom(user,chatRoom)){
ChatConfigEntity check = chatConfigRepository.findByUserAndChatRoom(user,chatRoom).orElseThrow(()->new AppException(ErrorCode.DB_ERROR,ErrorCode.DB_ERROR.getMessage()));
List<Chat> chats = chatRepository.findByChatRoom(chatRoom);
for(Chat chat : chats){
if(chat.getCreatedAt().isBefore(check.getConfigTime())){
index++;
}
}
return Response.success(CheckResponse.builder().index(index).build());
}else{
return Response.success(CheckResponse.builder().index(index).build());
}
}
최근 읽었던 시간을 반환하고, 해당 채팅방의 chat 내역을 불러와 읽었던 시간 까지의 인덱스를 체크해 내가 읽었던 위치를 저장한다.

이렇게 1번 사용자가 6번 채팅방에 접속을 했을때, 나간 시간을 기준으로 이전에 있는 시간 값이라면 index 를 카운트 해서 읽었던 위치를 알아낸다 ( 현재 17:32:48에 나갔으니, 그 이전값인 17:24:31 만 카운트 한다. )
해당 인덱스 위치에 HTML 방식으로 렌더링 해주면 읽었던 위치까지 표현이 가능하다. + 해당위치로 스크롤링까지
이 부분은 프론트관련 내용이기 때문에 생략 위 소스에서는 viewpoint 를 참고하면 된다.

읽은 위치 저장
퇴장 전에 현재 시간에 대해서 기록처리를 한다.
사용자가 채팅방에서 나가게 되면 현재까지 읽었던 위치까지 읽음 처리를 해야되는데, 위에서 나온 읽음에 대한 엔티티를 생성 또는 수정을 하면 된다.
window.addEventListener('beforeunload', () => {
// 명세에 따라 preventDefault는 호출해야하며, 기본 동작을 방지합니다.
var username = document.getElementById("myName").innerHTML;
stomp.send('/pub/chat/out', {}, JSON.stringify({roomId: roomId, writer: username, stat: 0}));
readSave();
});
사용자가 페이지에서 벗어날때 이벤트에 대한 함수이다.
이때 퇴장에 대한 메세지 전송과 채팅방 읽음 엔티티의 생성 또는 수정이 나타난다.
async function readSave() {
console.log("readSave");
let response = await fetch("/api/v1/chat/check", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
userName: document.getElementById("myName").innerHTML,
roomId: roomId
})
})
if (response.ok) {
let json = await response.json();
console.log(json);
}
console.log("readSave END");
}
마찬가지 사용자 정의함수로 비동기 방식으로 api 를 호출해서 현재 시간에 대해 저장하게 된다.
DTO는 현재 사용자명과 채팅방에 대한 정보를 넘긴다
@PostMapping("/check")
public Response checkSave(@RequestBody CheckRequest checkRequest){
return checkService.checkSave(checkRequest);
}
@Transactional
public Response checkSave(CheckRequest checkRequest){
Long roomId = checkRequest.getRoomId();
String userName = checkRequest.getUserName();
ChatRoom chatRoom = chatRoomRepository.findByRoomId(roomId).orElseThrow(()->new AppException(ErrorCode.DB_ERROR,ErrorCode.DB_ERROR.getMessage()));
User user = userRepository.findByUserName(userName).orElseThrow(()->new AppException(ErrorCode.USERID_NOT_FOUND,ErrorCode.USERID_NOT_FOUND.getMessage()));
if(chatConfigRepository.existsByUserAndChatRoom(user,chatRoom)){
ChatConfigEntity chatConfigEntity = chatConfigRepository.findByUserAndChatRoom(user,chatRoom).orElseThrow(()->new AppException(ErrorCode.DB_ERROR,ErrorCode.DB_ERROR.getMessage()));
chatConfigEntity.setTime();
return Response.success(chatConfigEntity.getConfigTime());
}else{
ChatConfigEntity chatConfigEntity = ChatConfigEntity.builder().user(user).chatRoom(chatRoom).configTime(LocalDateTime.now()).build();
chatConfigRepository.save(chatConfigEntity);
return Response.success(chatConfigEntity.getConfigTime());
}
}
DTO 로 받아온 사용자명과 채팅방에 대한 정보를 받아 ChatConfigEntity 를 만들어 현재시간을 반영해 저장해준다.

전체적인 플로우차트를 참고하기

마무리
- 채팅 내역을 저장할때, 전송이 이루어질때마다 api를 비동기 방식 호출하여 저장하게 되는데, 다중서버일때 동시성 문제라던지, 메시지가 중간 에러시 일관성을 유지할 수 있는가?, 엔터 한번에 한번의 호출에 대한 오버헤드 등 내역을 저장하는 방식을 바꿔줄 필요가 있을 듯 하다. redis 나 메세지 큐? 를 이용해보면 좋을 것 같다.
- 읽은 위치를 불러오기 위해서는 채팅내역에 대해서 다시한번더 불러오는 과정을 거치는데 이 점 또한 비효율적이며 개선해야된다 생각한다. 처음 설계한 환경이 문제라 대규모 수정작업이 필요할것으로 보인다. 정말 설계를 얼마나 꼼꼼히, 탄탄하게 하냐의 중요성을 깨달았다.
- 개발 간 javascript 와 css에 시간이 많이 걸렸던거같다. 비동기처리로 가져온 데이터를 화면에 어떻게 보여줄까라는 고민에 템플릿 활용과 스크립트 이해에 과한 시간이 투자되었었다.
진행하기 앞서 요구사항에 따른 기능 구현을 했고 해당 방법이 절대 정답이 아님을 말씀드리며, 한가지의 견해 정도로만 봐주셨으면 좋겠습니다!
자세한 소스코드는 깃허브 주소 참고 부탁드립니다.
https://github.com/rnrudejr9/poco_a_poco
GitHub - rnrudejr9/poco_a_poco: 오늘부터 운동메💪 "동네 운동 메이트를 찾아 함께 스포츠를 즐기는 서
오늘부터 운동메💪 "동네 운동 메이트를 찾아 함께 스포츠를 즐기는 서비스". Contribute to rnrudejr9/poco_a_poco development by creating an account on GitHub.
github.com
질문이나 의견제시는 언제나 댓글로 환영입니다. 감사합니다.
'2023년 > 멋쟁이사자처럼 팀프로젝트' 카테고리의 다른 글
[팀프로젝트] SockJs를 활용한 STOMP 방식 채팅방에서 실시간 사용자 체크하는 기능 구현하기 (0) | 2023.03.01 |
---|---|
[팀 프로젝트] 1명의 USER 가 n개의 모임에 참여했는지 여부를 파악하는 방법 구현하기 (0) | 2023.02.28 |
[팀프로젝트] datepicker / Timepicker 적용하기 (2) | 2023.02.07 |
[팀프로젝트] STOMP 활용한 프로젝트 채팅방 구현 (0) | 2023.02.02 |
[팀프로젝트] Spring Boot에서 jwt + 쿠키를 활용한 로그인 구현과 비동기 방식 처리 (0) | 2023.02.01 |