Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: WebSocket과 Kafka를 이용한 채팅 기능 임시 구현 #10

Merged
merged 23 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8777e8e
conf: WebSocket, Stomp등 의존성 추가
kjungw1025 Jan 23, 2024
77cfc95
feat: 채팅방 UI 확인을 위한 정적 리소스 파일들 추가
kjungw1025 Jan 23, 2024
efc128f
feat: WebSocket 연결을 위한 엔드포인트 설정 및, Stomp pub/sub 엔드포인트 설정
kjungw1025 Jan 23, 2024
ea61234
feat: SockJS 코드 추가
kjungw1025 Jan 23, 2024
ae9dd73
feat: 채팅방 관련 엔티티 추가
kjungw1025 Jan 23, 2024
712aa65
feat: 채팅방 상태, 웹소켓을 통해 보내는 메시지 타입에 대한 Enum 클래스 추가
kjungw1025 Jan 23, 2024
58a2bc7
feat: 채팅방, 채팅 메시지에 대한 dto 추가
kjungw1025 Jan 23, 2024
21e44ba
feat: 채팅방, 채팅방에 참여하고 있는 유저에 대한 리포지토리 추가
kjungw1025 Jan 23, 2024
79839db
feat: 존재하지 않는 채팅방에 대한 오류 메시지 추가
kjungw1025 Jan 23, 2024
698662f
feat: 채팅방 서비스 로직 추가
kjungw1025 Jan 23, 2024
8345955
feat: 채팅방 유저 정보 파악에 대한 서비스 로직 및 dto 추가
kjungw1025 Jan 23, 2024
a22528b
fix: 채팅방 유저 정보에 대한 메서드명 변경
kjungw1025 Jan 23, 2024
41ad628
feat: 채팅방 컨트롤러 로직 추가
kjungw1025 Jan 23, 2024
60eb938
feat: 채팅 메시지 송/수신 컨트롤러 로직 추가
kjungw1025 Jan 23, 2024
5c2883b
conf: Apache Kafka 의존성 추가
kjungw1025 Jan 23, 2024
809be76
conf: 카프카 Producer 및 Consumer 설정 클래스 작성
kjungw1025 Jan 23, 2024
beccfb4
feat: 메시지를 지정한 카프카 토픽으로 전송하는 서비스 로직 추가
kjungw1025 Jan 23, 2024
940abe0
feat: 카프카 컨슈머를 통해 가져온 메시지를 해당 채팅방을 구독하고 있는 구독자에게 발송하는 서비스 로직 추가
kjungw1025 Jan 23, 2024
1305c26
fix: 한글 메시지 JsonDeserializer 과정에서 발생하는 오류로 인해 StringDeserializer로 수정
kjungw1025 Jan 23, 2024
994005c
feat: 카프카를 통해 전달할 메시지 객체 생성
kjungw1025 Jan 23, 2024
c2b245d
feat: 채팅 메시지를 토픽으로 전송하고 리스너로 받는 로직 추가
kjungw1025 Jan 23, 2024
83db056
fix: broker 대체로 인한 컨트롤러 로직 변경
kjungw1025 Jan 23, 2024
6b26153
fix: 한글 메시지 오류에 대한 KafkaProducerConfig부분 StringDeserializer로 추가 수정
kjungw1025 Jan 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ dependencies {
implementation 'com.github.therapi:therapi-runtime-javadoc:0.15.0'
implementation 'org.springdoc:springdoc-openapi-ui:1.6.14'

// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

// sockjs
implementation 'org.webjars:sockjs-client:1.5.1'

// stomp
implementation 'org.webjars:stomp-websocket:2.3.4'

// kafka
implementation 'org.springframework.kafka:spring-kafka'

// gson
implementation 'com.google.code.gson:gson:2.9.0'

// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package com.dku.council.domain.chat.controller;

import com.dku.council.domain.chat.model.MessageType;
import com.dku.council.domain.chat.model.dto.Message;
import com.dku.council.domain.chat.model.dto.request.RequestChatDto;
import com.dku.council.domain.chat.model.dto.response.ResponseChatDto;
import com.dku.council.domain.chat.service.ChatService;
import com.dku.council.domain.chat.service.MessageSender;
import com.dku.council.global.auth.role.UserAuth;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

import java.util.List;

@Tag(name = "채팅", description = "채팅 송/수신 관련 api")
@Controller
@RequiredArgsConstructor
@Slf4j
public class ChatController {

@Value("${spring.kafka.consumer.topic}")
private String topic;

/**
* 아래에서 사용되는 convertAndSend 를 사용하기 위한 선언
* convertAndSend 는 객체를 인자로 넘겨주면 자동으로 Message 객체로 변환 후 도착지로 전송한다.
*/
private final SimpMessageSendingOperations template;

private final ChatService chatService;
private final MessageSender sender;

/**
* 채팅방 별, 입장 이벤트 발생시 처리되는 기능
*
* @param chat 채팅으로 보낼 메시지 정보 관련 param
*
* MessageMapping 을 통해 webSocket 로 들어오는 메시지를 발신 처리한다.
* 이때 클라이언트에서는 /pub/chat/sendMessage 로 요청하게 되고 이것을 controller 가 받아서 처리한다.
* 처리가 완료되면 /sub/chatRoom/enter/roomId 로 메시지가 전송된다.
*/
@MessageMapping("/chat/enterUser")
public void enterUser(@Payload RequestChatDto chat,
SimpMessageHeaderAccessor headerAccessor) {
// 채팅방 유저+1
chatService.plusUserCnt(chat.getRoomId());

// 채팅방에 유저 추가 및 UserUUID 반환
String username = chatService.addUser(chat.getRoomId(), chat.getSender());
log.info("enterUser에서 uuid " + username);
log.info("enterUser에서 roomId " + chat.getRoomId());

// 반환 결과를 socket session 에 userUUID 로 저장
headerAccessor.getSessionAttributes().put("username", username);
headerAccessor.getSessionAttributes().put("roomId", chat.getRoomId());

Message message = Message.builder()
.type(chat.getType())
.roomId(chat.getRoomId())
.sender(chat.getSender())
.message(chat.getSender() + " 님 입장!!")
.build();

sender.send(topic, message);
}

/**
* 채팅방 별, 채팅 메시지 전송 기능
*
* @param chat 채팅으로 보낼 메시지 정보 관련 param
*/
@MessageMapping("/chat/sendMessage")
public void sendMessage(@Payload RequestChatDto chat) {
log.info("CHAT {}", chat);

Message message = Message.builder()
.type(chat.getType())
.roomId(chat.getRoomId())
.sender(chat.getSender())
.message(chat.getMessage())
.build();

sender.send(topic, message);
}

/**
* 채팅방 별, 퇴장 이벤트 발생시 처리되는 기능
*
* 유저 퇴장 시에는 EventListener 을 통해서 유저 퇴장을 확인
*/
@EventListener
public void webSocketDisconnectListener(SessionDisconnectEvent event) {
log.info("DisConnEvent {}", event);

StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

// stomp 세션에 있던 username과 roomId 를 확인해서 채팅방 유저 리스트와 room 에서 해당 유저를 삭제
String username = (String) headerAccessor.getSessionAttributes().get("username");
String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
log.info("퇴장 controller에서 uuid " + username);
log.info("퇴장 controller에서 roomId " + roomId);

log.info("headAccessor {}", headerAccessor);

// 채팅방 유저 -1
chatService.minusUserCnt(roomId);

// 채팅방 유저 리스트에서 유저 삭제
chatService.delUser(roomId, username);

if (username != null) {
log.info("User Disconnected : ", username);

// builder 어노테이션 활용
Message message = Message.builder()
.type(MessageType.LEAVE)
.sender(username)
.roomId(roomId)
.message(username + " 님 퇴장!!")
.build();

sender.send(topic, message);
}
}

/**
* 채팅방 별, 채팅에 참여한 유저 리스트 반환
*
* @param roomId 채팅방 id
*/
@GetMapping("/chat/userlist")
@UserAuth
@ResponseBody
public List<String> userList(String roomId) {
return chatService.getUserList(roomId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.dku.council.domain.chat.controller;

import com.dku.council.domain.chat.model.dto.response.ResponseChatRoomDto;
import com.dku.council.domain.chat.service.ChatService;
import com.dku.council.domain.user.model.dto.response.ResponseUserInfoForChattingDto;
import com.dku.council.domain.user.service.UserService;
import com.dku.council.global.auth.jwt.AppAuthentication;
import com.dku.council.global.auth.role.UserAuth;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Tag(name = "채팅방", description = "채팅방 관련 api")
@Controller
@RequestMapping("/chatRoom")
@RequiredArgsConstructor
@Slf4j
public class ChatRoomController {
@Autowired
private ChatService chatService;

private final UserService userService;

/**
* 채팅방 리스트 화면
*/
@GetMapping
@UserAuth
public String goChatRoom(Model model, AppAuthentication auth) {
ResponseUserInfoForChattingDto responseUserInfoForChattingDto = userService.getUserInfoForChatting(auth.getUserId());

model.addAttribute("list", chatService.findAllRoom());
model.addAttribute("user", responseUserInfoForChattingDto);

log.info("SHOW ALL ChatList {}", chatService.findAllRoom());

return "/page/chatting/roomlist";
}

/**
* 채팅방 생성
*
* @param name 채팅방 이름
* @param roomPwd 채팅방 비밀번호
* @param secretCheck 채팅방 잠금 설정 여부
* @param maxUserCount 채팅방 최대 인원 수 설정 (default = 10)
*/
@PostMapping("/create")
@UserAuth
public String createRoom(@RequestParam("roomName") String name,
@RequestParam("roomPwd") String roomPwd,
@RequestParam("secretChk") String secretCheck,
@RequestParam(value = "maxUserCount", defaultValue = "10") String maxUserCount,
AppAuthentication auth,
RedirectAttributes rttr) {

ResponseChatRoomDto room = chatService.createChatRoom(name,
roomPwd,
Boolean.parseBoolean(secretCheck),
Integer.parseInt(maxUserCount),
auth.getUserId());

log.info("CREATE Chat Room [{}]", room);

rttr.addFlashAttribute("roomName", room);
return "redirect:/chatRoom";
}


// 채팅방 입장 화면
// 파라미터로 넘어오는 roomId 를 확인후 해당 roomId 를 기준으로
// 채팅방을 찾아서 클라이언트를 chatroom 으로 보낸다.
@GetMapping("/enter")
@UserAuth
public String roomDetail(Model model, String roomId, AppAuthentication auth){

log.info("/chatRoom/enter : roomId {}", roomId);

model.addAttribute("user", userService.getUserInfoForChatting(auth.getUserId()));
model.addAttribute("room", chatService.findRoomById(roomId));

return "/page/chatting/chatroom";
}

/**
* 채팅방 비밀번호 확인
*
* @param roomId 채팅방 id
* @param roomPwd 사용자가 입력한 비밀번호
* @return 사용자가 입력한 비밀번호가 일치하면 true, 아니면 false
*/
@PostMapping("/confirmPwd/{roomId}")
@UserAuth
@ResponseBody
public boolean confirmPwd(@PathVariable String roomId,
@RequestParam String roomPwd){

return chatService.confirmPwd(roomId, roomPwd);
}

/**
* 채팅방 삭제
*
* @param roomId 채팅방 id
*/
@DeleteMapping("/delete/{roomId}")
@UserAuth
public String delChatRoom(@PathVariable String roomId, AppAuthentication auth){

// roomId(UUID 값) 기준으로 채팅방 삭제
chatService.delChatRoom(auth.getUserId(), roomId, auth.isAdmin());

return "redirect:/chatRoom";
}

/**
* maxUserCount에 따른 채팅방 입장 여부
*
* @param roomId 채팅방 Id
* @return true/false
*/
@GetMapping("/chkUserCnt/{roomId}")
@ResponseBody
public boolean chkUserCnt(@PathVariable String roomId){

return chatService.chkRoomUserCnt(roomId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.dku.council.domain.chat.exception;

import com.dku.council.global.error.exception.LocalizedMessageException;
import org.springframework.http.HttpStatus;

public class ChatRoomNotFoundException extends LocalizedMessageException {
public ChatRoomNotFoundException() { super(HttpStatus.NOT_FOUND, "notfound.chat-room"); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.dku.council.domain.chat.model;

public enum ChatRoomStatus {
/**
* 활성화 상태
*/
ACTIVE,

/**
* 닫힌 상태
*/
CLOSED,

/**
* 삭제된 상태
*/
DELETED,

/**
* 운영자에 의해 삭제된 상태
*/
DELETED_BY_ADMIN
}
19 changes: 19 additions & 0 deletions src/main/java/com/dku/council/domain/chat/model/MessageType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.dku.council.domain.chat.model;

public enum MessageType {
/**
* 채팅방 입장
*/
ENTER,

/**
* 채팅방 대화중
* (해당 채팅방을 sub하고 있는 모든 client들에게 전달됨)
*/
TALK,

/**
* 채팅방 퇴장
*/
LEAVE;
}
Loading