본문 바로가기
Spring & Spring Boot

[Spring] 실시간 채팅 기능 구현(2) - Entity, Controller, Service

by wook99 2024. 11. 12.

저번 포스팅에서 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가 삽입된다.
  • getWaitingRooms 메서드 - ADMIN만 사용 가능
    • 현재 대기 상태(WAITING)인 채팅방 목록을 조회한다.
  • activateChatRoom 메서드 - ADMIN만 사용 가능
    • User가 만든 WaitingRoom을 ADMIN이 활성화하여 USER와 ADMIN 모두 채팅방이 활성화 된다.
      이 때, DB에 status가 WAITING에서 ACTIVE로, admin_id는 채팅방을 활성화한 관리자의 admin_id로 업데이트된다.
  • 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를 사용해서 클라이언트 측 코드를 작성해보겠다.