저번 포스팅에서 WebSocket,그리고 Controller 관련 설정까지 마쳤다.
https://wook99.tistory.com/115
[Spring] 실시간 채팅 기능 구현(1) - WebSocket, 그리고 STOMP
얼마 전 팀 프로젝트에서 구현했던 실시간 채팅 기능을 포스팅 해보려고 한다. 주요 개발 환경은 다음과 같다. Java - JDK21Spring Boot - 3.3.4React 구상했던 흐름도는 이렇다.1. 유저가 채팅 카테고리
wook99.tistory.com
오늘은 Entity 작성,Controller, 그리고 Service까지 작성해보자
Entity
ChatRoom.java
package com.team5.pyeonjip.chat.entity;
import com.team5.pyeonjip.global.entity.BaseTimeEntity;
import com.team5.pyeonjip.user.entity.User;
import jakarta.persistence.*;
import lombok.*;
import java.sql.Timestamp;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "chat_room")
public class ChatRoom extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "category", nullable = false)
private String category;
@Enumerated(EnumType.STRING)
@Column(name = "status")
private ChatRoomStatus status;
@Column(name = "closed_at")
private Timestamp closedAt;
@ManyToOne
@JoinColumn(name = "user_id",referencedColumnName = "id", nullable = false)
private User user;
@ManyToOne
@JoinColumn(name = "admin_id", referencedColumnName = "id", nullable = true)
private User admin;
public void updateAdmin(User admin){
this.admin = admin;
}
public void updateStatus(ChatRoomStatus status){
this.status = status;
}
}
public enum ChatRoomStatus {
WAITING, ACTIVE, CLOSED
}
특이사항
- status 컬럼이 Enum타입이다. status에는 WAITING, ACTIVE, CLOSED 이 세 가지의 값만 들어갈 것이므로 ENUM으로 설정했다.
- Admin이라는 테이블은 따로 존재하지 않고, User 내에 Role 컬럼이 Enum타입으로 USER, ADMIN으로 구분된다.
- status가 WAITING이나 ACTIVE인 채팅방이 있을 수 있으므로 status는 null을 허용한다.
- 이 구조로 USER와 ADMIN 간의 관계 추적이 가능하고, 채팅방의 상태 변화와 채팅 이력 및 종료시간이 추적 가능하다.
ChatMessage.java
package com.team5.pyeonjip.chat.entity;
import com.team5.pyeonjip.global.entity.BaseTimeEntity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
@Entity
@Table(name = "chat_message")
public class ChatMessage extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "sender_email", nullable = false)
private String senderEmail;
@Column(name = "message", nullable = false)
@NotEmpty
@Size(max = 200)
private String message;
@ManyToOne
@JoinColumn(name = "chat_room_id", referencedColumnName = "id", nullable = false)
private ChatRoom chatRoom;
public void updateMessage(String message){
this.message = message;
}
}
특이사항
- 이러한 구조로 메시지와 채팅방 간의 관계 추적 가능
- 메시지 200자 제한.
BaseTimeEntity.java
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "TIMESTAMP")
private Timestamp createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false, columnDefinition = "TIMESTAMP")
private Timestamp updatedAt;
}
ChatRoom과 ChatMessage 엔티티는 BaseTimeEntity를 상속받는다.
이 추상 클래스를 상속 받음으로써 생성, 수정 현상이 발생할 때 자동으로 생성,수정 시간이 저장된다.
ex) 메시지 보내기, 메시지 수정, 채팅방 생성, 채팅방 status 업데이트
Controller
ChatController.java
package com.team5.pyeonjip.chat.controller;
import com.team5.pyeonjip.chat.dto.ChatMessageDto;
import com.team5.pyeonjip.chat.dto.ChatRoomDto;
import com.team5.pyeonjip.chat.service.ChatMessageService;
import com.team5.pyeonjip.chat.service.ChatRoomService;
import com.team5.pyeonjip.user.dto.CustomUserDetails;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatRoomService chatRoomService;
private final ChatMessageService chatMessageService;
private final SimpMessagingTemplate messagingTemplate;
// userId에 따른 채팅이력 리스트
@GetMapping("/chat-room-list/{email}")
public ResponseEntity<List<ChatRoomDto>> getChatRooms(@PathVariable("email") String email){
List<ChatRoomDto> chatRoom = chatRoomService.getChatRoomsByUserEmail(email);
return ResponseEntity.ok().body(chatRoom);
}
// 대기 상태의 채팅방 생성
@PostMapping("/waiting-room")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<ChatRoomDto> createWaitingRoom(@RequestBody ChatRoomDto chatRoomDto, Authentication authentication) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
String userEmail = userDetails.getUsername(); // CustomUserDetails에서는 getUsername()이 이메일을 반환합니다.
// userId 대신 userEmail을 사용하도록 ChatRoomService 메서드를 수정해야 할 수 있습니다.
ChatRoomDto createdChatRoom = chatRoomService.createWaitingRoom(chatRoomDto.getCategory(), userEmail);
return ResponseEntity.ok(createdChatRoom);
}
@GetMapping("/waiting-rooms")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<ChatRoomDto>> getWaitingRooms() {
List<ChatRoomDto> waitingRooms = chatRoomService.getWaitingChatRooms();
return ResponseEntity.ok(waitingRooms);
}
// 관리자용: 채팅방 활성화
@PostMapping("/activate-room/{chatRoomId}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ChatRoomDto> activateChatRoom(@PathVariable("chatRoomId") Long chatRoomId, Authentication authentication) {
CustomUserDetails admin = (CustomUserDetails) authentication.getPrincipal();
String adminEmail = admin.getUsername(); // CustomUserDetails에서는 getUsername()이 이메일을 반환합니다.
ChatRoomDto activatedRoom = chatRoomService.activateChatRoom(chatRoomId, adminEmail);
return ResponseEntity.ok(activatedRoom);
}
@PostMapping("/close-room/{chatRoomId}")
public ResponseEntity<?> closeChatRoom(@PathVariable("chatRoomId") Long chatRoomId) {
try {
ChatRoomDto closedRoom = chatRoomService.closeChatRoom(chatRoomId);
messagingTemplate.convertAndSend("/topic/chat-room-closed/" + chatRoomId, closedRoom);
return ResponseEntity.ok(closedRoom);
} catch (Exception e) {
return ResponseEntity.badRequest().body("Failed to close chat room: " + e.getMessage());
}
}
@GetMapping("/chat-room/{chatRoomId}")
public ResponseEntity<ChatRoomDto> getChatRoom(@PathVariable("chatRoomId") Long chatRoomId) {
ChatRoomDto chatRoom = chatRoomService.getChatRoomById(chatRoomId);
return ResponseEntity.ok(chatRoom);
}
// 채팅방에 따른 채팅 메시지 조회
@GetMapping("/chat-message-history/{chatRoomId}")
public ResponseEntity<List<ChatMessageDto>> getChatMessages(@PathVariable("chatRoomId") Long chatRoomId){
List<ChatMessageDto> chatMessage = chatMessageService.getChatMessagesByChatRoomId(chatRoomId);
return ResponseEntity.ok().body(chatMessage);
}
}
특이사항
- getChatRooms 메서드 - 특정 사용자의 채팅방 목록을 이메일 기준으로 조회한다.
- 이전 문의 내역을 통해 사용자가 이전에 진행했던 채팅 내역을 조회
- createWaitingRoom 메서드 - USER만 사용 가능
- Admin이 아닌 User가 채팅 대기방을 만들고 관리자를 기다리는 시스템이다.
따라서 WaitingRoom은 유저가 만들어야 한다.
이 때, DB에는 status가 WAITING, admin_id는 null인 상태로 chat_room 테이블에 raw가 삽입된다.
- Admin이 아닌 User가 채팅 대기방을 만들고 관리자를 기다리는 시스템이다.
- getWaitingRooms 메서드 - ADMIN만 사용 가능
- 현재 대기 상태(WAITING)인 채팅방 목록을 조회한다.
- activateChatRoom 메서드 - ADMIN만 사용 가능
- User가 만든 WaitingRoom을 ADMIN이 활성화하여 USER와 ADMIN 모두 채팅방이 활성화 된다.
이 때, DB에 status가 WAITING에서 ACTIVE로, admin_id는 채팅방을 활성화한 관리자의 admin_id로 업데이트된다.
- User가 만든 WaitingRoom을 ADMIN이 활성화하여 USER와 ADMIN 모두 채팅방이 활성화 된다.
- closeChatRoom 메서드
- 채팅방 종료 버튼을 눌러 채팅방을 종료 상태로 만든다.
이 때, DB에 status가 CLOSED로 업데이트 된다. - WebSocket을 통해 종료 알림을 전송한다.
- 채팅방 종료 버튼을 눌러 채팅방을 종료 상태로 만든다.
- getChatRoom 메서드
- USER가 이전 문의 내역에서 채팅방을 선택했을 때, 해당 채팅방을 불러온다.
- getChatMessages 메서드
- 채팅방을 불러온 후 메시지를 불러온다.
ChatMessageController.java
package com.team5.pyeonjip.chat.controller;
import com.team5.pyeonjip.chat.dto.ChatMessageDto;
import com.team5.pyeonjip.chat.service.ChatMessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import java.security.Principal;
@Controller
@RequiredArgsConstructor
public class ChatMessageController {
private final ChatMessageService chatMessageService;
private final SimpMessagingTemplate messagingTemplate;
@MessageMapping("/chat.sendMessage/{chatRoomId}")
public void sendMessage(@DestinationVariable("chatRoomId") Long chatRoomId,
@Payload ChatMessageDto message,
@Header("simpUser") Principal principal) {
String senderEmail = principal.getName();
ChatMessageDto createdMessage = chatMessageService.sendMessage(chatRoomId, message.getMessage(), senderEmail);
messagingTemplate.convertAndSend("/topic/messages/" + chatRoomId, createdMessage);
}
@MessageMapping("/chat.updateMessage/{chatRoomId}")
public void updateMessage(@DestinationVariable("chatRoomId") Long chatRoomId,
@Payload ChatMessageDto message,
@Header("simpUser") Principal principal) {
String senderEmail = principal.getName();
ChatMessageDto updatedMessage = chatMessageService.updateMessage(message.getId(), message.getMessage(), senderEmail);
messagingTemplate.convertAndSend("/topic/message-updates/" + chatRoomId, updatedMessage);
}
@MessageMapping("/chat.deleteMessage/{chatRoomId}")
public void deleteMessage(@DestinationVariable("chatRoomId") Long chatRoomId,
@Payload ChatMessageDto message,
@Header("simpUser") Principal principal) {
String senderEmail = principal.getName();
Long deletedMessageId = chatMessageService.deleteMessage(message.getId(), senderEmail);
messagingTemplate.convertAndSend("/topic/message-deletions/" + chatRoomId, deletedMessageId);
}
}
특이사항
- sendMessage 메서드 - 메시지 보내기
- 클라이언트 호출 경로 : /app/chat.sendMessage/{chatRoomId}
- 메시지를 저장하고 해당 채팅방의 구독자들에게 브로드캐스트
- 구독자들은 /topic/messages/{chatRoomId} 로 메시지를 수신
- updateMessage 메서드 - 메시지 수정
- 클라이언트 호출 경로 : /app/chat.updateMessage/{chatRoomId}
- 기존 메시지를 수정하고 변경 사항을 구독자들에게 통지
- 구독자들은 /topic/message-updates/{chatRoomId} 로 수신
- deleteMessage 메서드 - 메시지 삭제
- 클라이언트 호출 경로 : /app/chat.deleteMessage/{chatRoomId}
- 메시지를 삭제하고 삭제 알림을 구독자들에게 전송
- 구독자들은 /topic/messae-deletions/{chatRoomId}로 수신
- 파라미터
- chatRoomId: 채팅방 식별자
- message: 전송할 메시지 내용
- principal: 발신자 정보
메시지의 흐름
- Client 1 메시지 전송 - 서버 (메시지 저장 및 처리) - Client 1, Client 2 에 /topic/messages/{roomId} 로 브로드 캐스트
- Client 1 메시지 수정 - 서버 (메시지 수정 및 검증) - Client 1, Client 2 에 /topic/ message-updates/{roomId} 로 브로드 캐스트
- Client 1 메시지 삭제 - 서버 (메시지 수정 및 검증) - Client 1, Client 2 에 /topic/ message-deletions/{roomId} 로 브로드 캐스트
Sevice
ChatRoomService.java
package com.team5.pyeonjip.chat.service;
import com.team5.pyeonjip.chat.dto.ChatRoomDto;
import com.team5.pyeonjip.chat.entity.ChatRoom;
import com.team5.pyeonjip.chat.entity.ChatRoomStatus;
import com.team5.pyeonjip.chat.mapper.ChatRoomMapper;
import com.team5.pyeonjip.chat.repository.ChatRoomRepository;
import com.team5.pyeonjip.global.exception.ErrorCode;
import com.team5.pyeonjip.global.exception.GlobalException;
import com.team5.pyeonjip.user.entity.User;
import com.team5.pyeonjip.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ChatRoomService {
private final ChatRoomRepository chatRoomRepository;
private final UserRepository userRepository;
private final ChatRoomMapper chatRoomMapper;
private final SimpMessagingTemplate messagingTemplate;
public ChatRoomDto getChatRoomById(Long roomId) {
ChatRoom chatRoom = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new GlobalException(ErrorCode.CHAT_ROOM_NOT_FOUND));
return chatRoomMapper.toDTO(chatRoom);
}
public ChatRoomDto createWaitingRoom(String category, String userEmail) {
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND));
ChatRoom chatRoom = ChatRoom.builder()
.category(category)
.status(ChatRoomStatus.WAITING)
.user(user)
.build();
ChatRoom savedChatRoom = chatRoomRepository.save(chatRoom);
return chatRoomMapper.toDTO(savedChatRoom);
}
@Transactional
public ChatRoomDto activateChatRoom(Long chatRoomId, String adminEmail) {
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId)
.orElseThrow(() -> new GlobalException(ErrorCode.CHAT_ROOM_NOT_FOUND));
if (chatRoom.getStatus() != ChatRoomStatus.WAITING) {
throw new GlobalException(ErrorCode.WAITING_ROOM_ACTIVATE);
}
User admin = userRepository.findByEmail(adminEmail)
.orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND));
User user = chatRoom.getUser();
chatRoom.updateStatus(ChatRoomStatus.ACTIVE);
chatRoom.updateAdmin(admin);
ChatRoom updatedChatRoom = chatRoomRepository.save(chatRoom);
ChatRoomDto updatedRoomDto = chatRoomMapper.toDTO(updatedChatRoom);
updatedRoomDto.setUserEmail(user.getEmail());
// 사용자에게 채팅방 활성화 알림
messagingTemplate.convertAndSendToUser(
chatRoom.getUser().getEmail(),
"/queue/chat-room-activated",
updatedRoomDto
);
return updatedRoomDto;
}
public ChatRoomDto closeChatRoom(Long chatRoomId){
ChatRoom room = chatRoomRepository.findById(chatRoomId)
.orElseThrow(() -> new GlobalException(ErrorCode.CHAT_ROOM_NOT_FOUND));
room.setStatus(ChatRoomStatus.CLOSED);
LocalDateTime now = LocalDateTime.now();
Timestamp currentTime = Timestamp.valueOf(now);
room.setClosedAt(currentTime);
ChatRoom closedChatRoom = chatRoomRepository.save(room);
return chatRoomMapper.toDTO(closedChatRoom);
}
public List<ChatRoomDto> getChatRoomsByUserEmail(String email){
List<ChatRoom> chatRooms = chatRoomRepository.findByUserEmailOrderByClosedAtDesc(email);
List<ChatRoomDto> chatRoomDtos = new ArrayList<>();
for (ChatRoom chatRoom : chatRooms) {
chatRoomDtos.add(chatRoomMapper.toDTO(chatRoom));
}
return chatRoomDtos;
}
public List<ChatRoomDto> getWaitingChatRooms() {
List<ChatRoom> waitingRooms = chatRoomRepository.findByStatus(ChatRoomStatus.WAITING);
return waitingRooms.stream()
.map(chatRoomMapper::toDTO)
.collect(Collectors.toList());
}
}

헷갈릴 수도 있는데, 대기 채팅방 조회는 ADMIN만 가능하다.
ChatMessageService.java
package com.team5.pyeonjip.chat.service;
import com.team5.pyeonjip.chat.dto.ChatMessageDto;
import com.team5.pyeonjip.chat.dto.ChatRoomDto;
import com.team5.pyeonjip.chat.entity.ChatMessage;
import com.team5.pyeonjip.chat.entity.ChatRoom;
import com.team5.pyeonjip.chat.mapper.ChatMessageMapper;
import com.team5.pyeonjip.chat.mapper.ChatRoomMapper;
import com.team5.pyeonjip.chat.repository.ChatMessageRepository;
import com.team5.pyeonjip.chat.repository.ChatRoomRepository;
import com.team5.pyeonjip.global.exception.ErrorCode;
import com.team5.pyeonjip.global.exception.GlobalException;
import com.team5.pyeonjip.global.exception.ResourceNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatMessageService {
private final ChatMessageRepository chatMessageRepository;
private final ChatRoomRepository chatRoomRepository;
private final ChatMessageMapper chatMessageMapper;
public List<ChatMessageDto> getChatMessagesByChatRoomId(Long chatRoomId){
List<ChatMessage> chatMessages = chatMessageRepository.findByChatRoomId(chatRoomId);
List<ChatMessageDto> chatMessageDtos = new ArrayList<>();
for (ChatMessage chatMessage : chatMessages) {
chatMessageDtos.add(chatMessageMapper.toDTO(chatMessage));
}
return chatMessageDtos;
}
public ChatMessageDto sendMessage(Long chatRoomId, String message, String senderEmail) {
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId)
.orElseThrow(() -> new GlobalException(ErrorCode.CHAT_ROOM_NOT_FOUND));
ChatMessage chatMessage = ChatMessage.builder()
.chatRoom(chatRoom)
.message(message)
.senderEmail(senderEmail)
.build();
chatMessage = chatMessageRepository.save(chatMessage);
return chatMessageMapper.toDTO(chatMessage);
}
@Transactional
public ChatMessageDto updateMessage(Long messageId, String message, String senderEmail) {
ChatMessage chatMessage = chatMessageRepository.findById(messageId)
.orElseThrow(() -> {
log.error("Message not found with ID: {}", messageId);
return new GlobalException(ErrorCode.CHAT_MESSAGE_NOT_FOUND);
});
if (!chatMessage.getSenderEmail().equals(senderEmail)) {
throw new GlobalException(ErrorCode.UNAUTHORIZED_MESSAGE_MODIFICATION);
}
chatMessage.updateMessage(message);
chatMessageRepository.save(chatMessage);
return chatMessageMapper.toDTO(chatMessage);
}
@Transactional
public Long deleteMessage(Long messageId, String senderEmail) {
ChatMessage chatMessage = chatMessageRepository.findById(messageId)
.orElseThrow(() -> {
log.error("Message not found with ID: {}", messageId);
return new GlobalException(ErrorCode.CHAT_MESSAGE_NOT_FOUND);
});
if (!chatMessage.getSenderEmail().equals(senderEmail)) {
throw new GlobalException(ErrorCode.UNAUTHORIZED_MESSAGE_DELETION);
}
chatMessageRepository.delete(chatMessage);
return messageId;
}
}

여기까지 Entity와 Controller 그리고 Service에 비즈니스 로직까지 작성을 했다.
다음 포스팅은 React를 사용해서 클라이언트 측 코드를 작성해보겠다.
'Spring & Spring Boot' 카테고리의 다른 글
| [Spring Boot] 공통 response 포맷 만들기 (0) | 2024.11.21 |
|---|---|
| [Spring] 실시간 채팅 기능 구현(1) - WebSocket, 그리고 STOMP (6) | 2024.11.11 |
| [Spring] JSP vs Thymeleaf (0) | 2024.11.11 |
| [Spring] DTO와 Entity의 분리. 왜? 어떻게? (2) | 2024.11.07 |
| [Spring] DTO vs Entity vs VO (1) | 2024.11.07 |