Spring WebFlux를 이용한 chat 프로그램 - 채팅(1)

2025. 5. 9. 18:04JAVA/Spring Boot

채팅방 목록까지 만들었고 채팅방 목록을 클릭하면 채팅방에 들어올 수 있도록 처리 했다.

채팅방에 들어왔을때 UI는 다음과 같다.

왼쪽에는 내가 참여한 채팅방 목록 오른쪽에는 참여자 목록 그리고 가운데는 채팅내용이 들어가도록 처리 하려고 한다.


역시 백엔드 먼저 작업을 시작한다.

DB Table 생성

CREATE TABLE chatroom_user (
    chatroom_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    CONSTRAINT chatroom_user_pkey PRIMARY KEY (chatroom_id, user_id)
);

채팅방에 참여한 인원관리 테이블이다.


DTO 생성

 

ChatRoomUser.java

package com.company.common.database.entity;

import org.springframework.data.relational.core.mapping.Table;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 채팅방 참여자 엔티티 (PostgreSQL)
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table("chatroom_user")
public class ChatRoomUser {
	private Long chatroomId;
	private Long userId;
}

채팅방 참여자 목록 관리 Entity

 

Repository 생성

 

ChatRoomUserRepository.java

package com.company.chat.repository;

import org.springframework.data.repository.reactive.ReactiveCrudRepository;

import com.company.common.database.entity.ChatRoomUser;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

/**
 * 채팅방 참여자 관리 Repository
 */
public interface ChatRoomUserRepository extends ReactiveCrudRepository<ChatRoomUser, Long> {

    /**
     * 특정 채팅방에 해당하는 사용자 목록을 조회한다.
     * 
     * @param roomId
     * @return Flux<ChatRoomUser>
     */
    Flux<ChatRoomUser> findByChatroomId(Long roomId);

    /**
     * 채팅방에 사용자가 있는지 여부를 확인한다.
     * 
     * @param roomId
     * @param userId
     * @return
     */
    Mono<Boolean> existsByChatroomIdAndUserId(Long roomId, Long userId);

    /**
     * 체팅방에서 퇴장 처리한다.
     * 
     * @param roomId
     * @param userId
     * @return
     */
    Mono<Void> deleteByChatroomIdAndUserId(Long roomId, Long userId);

    /**
     * 사용자가 등록되어 있는 채팅방 목록 정보를 조회한다.
     * 
     * @param userId
     * @return
     */
    Flux<ChatRoomUser> findByUserId(Long userId);
}

 

기존에 있던 ChatRoomRepository.java에 일부 추가한다.

    /**
     * 참여한 채팅방 id에 해당하는 채팅방 목록을 조회 한다.
     * @param ids
     * @return
     */
    @Query("SELECT * FROM chatrooms WHERE id IN (:ids)")
    Flux<ChatRoom> findByIdIn(Collection<Long> ids);

Service에서 내가 참여하고 있는 방 목록의 ID를 chatroom_user 테이블에서 조회한 후 chatrooms 테이블에서 다시 조회하는 쿼리이다.

 

Service 생성

 

ChatRoomParticipantService.java

package com.company.chat.service;

import java.util.List;

import org.springframework.stereotype.Service;

import com.company.chat.repository.ChatRoomUserRepository;
import com.company.chat.repository.UserRepository;
import com.company.common.database.entity.ChatRoomUser;
import com.company.common.database.entity.User;

import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;

/**
 * 채팅 참여자 관리 서비스
 */
@Service
@RequiredArgsConstructor
public class ChatRoomParticipantService {

	private final ChatRoomUserRepository chatRoomUserRepository;

	private final UserRepository userRepository;

	/**
	 * ChatroomUser를 조회 한 후에 user 를 조회하여 목록을 반환한다.
	 * @param roomId
	 * @return
	 */
	public Mono<List<User>> findParticipantsByRoomId(Long roomId) {
	    return chatRoomUserRepository.findByChatroomId(roomId)         // Flux<ChatRoomUser>
	             .flatMap(rel -> userRepository.findById(rel.getUserId())) // Flux<User>
	             .collectList(); // Mono<List<User>>
	}

	/**
	 * 채팅방 참여자 정보를 등록한다.
	 * 등록되어 있는지 여부를 판단하여 등록되어 있지 않은 경우에만 등록을 하도록 한다.
	 * @param roomId
	 * @param userId
	 * @return
	 */
	public Mono<Void> registerParticipant(Long roomId, Long userId) {

	    return chatRoomUserRepository.existsByChatroomIdAndUserId(roomId, userId)
	            .flatMap(exists -> {

	                if (exists) {
	                    return Mono.empty(); // 이미 등록되어 있음
	                }

	                ChatRoomUser relation = ChatRoomUser.builder()
	                                        .chatroomId(roomId)
	                                        .userId(userId)
	                                        .build();
	                return chatRoomUserRepository.save(relation).then();
	            });

	}

	/**
	 * 참여자를 삭제 처리한다.
	 * @param roomId
	 * @param userId
	 * @return
	 */
	public Mono<Void> removeParticipant(Long roomId, Long userId) {
		return chatRoomUserRepository.deleteByChatroomIdAndUserId(roomId, userId);
	}
}

 

서비스 또한 기존에 있던 ChatRoomService.java에 일부 추가한다.

private final ChatRoomUserRepository chatRoomUserRepository;

    /**
     * 나의 채팅방 목록 조회
     * @return
     */
    public Flux<ChatRoom> selectMyChateRooms(Long userId) {
        return chatRoomUserRepository.findByUserId(userId)      // Flux<ChatRoomUser>
                .map(ChatRoomUser::getChatroomId)               // Flux<Long>
                .collectList()                                  // Mono<List<Long>>
                .flatMapMany(chatRoomRepository::findByIdIn);   // Flux<ChatRoom>
    }

내가 참여한 채팅방 목록을 조회하는 항목을 추가하였다.

 

Controller 내용 일부 추가

 

기존에 있던 ChatRoomController.java 에 내용을 추가한다.

    private final ChatRoomParticipantService chatRoomParticipantService;

    /**
     * 내가 참여한 채팅방 목록 조회
     * @param request
     * @return
     */
    @GetMapping("/my")
    public Mono<ResponseEntity<ApiResponse<List<ChatRoom>>>> selectMyChateRooms(ServerHttpRequest request) {

        User user = (User) request.getAttributes().get("user"); // 필터에서 저장한 사용자 정보

        return chatRoomService.selectMyChateRooms(user.getId())
                                .collectList()
                                .map(rooms -> ResponseEntity.ok(new ApiResponse<>(true, "나의채팅방 목록", rooms)) );
    }

    /**
     * 채팅방에 포함되어 있는 참여자 정보를 조회한다.
     * @param roomId
     * @return
     */
    @GetMapping("/{roomId}/participants")
    public Mono<ResponseEntity<ApiResponse<List<User>>>> selectParticipants(@PathVariable Long roomId) {
    	return chatRoomParticipantService.findParticipantsByRoomId(roomId)
    			.map(users -> ResponseEntity.ok(new ApiResponse<>(true, "참여자 목록", users)));
    }


    /**
     * 채팅방 입장 처리 한다.
     * @param roomId
     * @param token
     * @return
     */
    @PostMapping("/{roomId}/enter")
    public Mono<ResponseEntity<ApiResponse<Void>>> enterChatRoom(@PathVariable Long roomId, ServerHttpRequest request) {

        User user = (User) request.getAttributes().get("user"); // 필터에서 저장한 사용자 정보

        return chatRoomParticipantService.registerParticipant(roomId, user.getId())
				.thenReturn(ResponseEntity.ok(new ApiResponse<>(true, "입장 처리 완료!!!", null)));

    }

    /**
     * 채팅방에서 퇴장 처리 한다.
     * @param roomId
     * @param token
     * @return
     */
    @DeleteMapping("/{roomId}/exit")
    public Mono<ResponseEntity<ApiResponse<Void>>> exitChatRoom(@PathVariable Long roomId, ServerHttpRequest request) {

        User user = (User) request.getAttributes().get("user"); // 필터에서 저장한 사용자 정보

    	return chatRoomParticipantService.removeParticipant(roomId, user.getId())
    					.thenReturn(ResponseEntity.ok(new ApiResponse<>(true, "퇴장처리 완료", null)));
    }

내가 참여한 채팅방 목록 및 채팅방 입장 등 내용이 추가 되었다.


다음은 프론트엔드 처리!!!!

기존에 만들어 져 있던 ChatRoomService.js에 항목을 추가한다.

    /**
     * 내가 참여한 채팅방 목록을 조회하는 API
     * @returns {Promise<Response<ChatRoom[]>>}
     */
    async selectMyChatRoom() {
        return await axiosInstance.get('/chat/chatroom/my')
    }
    
    /**
     * 채팅방에 참여한 참여자 목록 조회 API
     * @param {string} roomId 
     * @returns 
     */
    async selectParticipants(roomId) {
        return await axiosInstance.get(`/chat/chatroom/${roomId}/participants`)
    }

    /**
     * 채팅방에 입장 처리 한다.
     * @param {string} roomId 
     * @returns 
     */
    async enterChatRoom(roomId) {
        return await axiosInstance.post(`/chat/chatroom/${roomId}/enter`)
    }

    /**
     * 채팅방에서 퇴장 처리 한다.
     * @param {string} roomId 
     * @returns 
     */
    async exitChatRoom(roomId) {
        return await axiosInstance.delete(`/chat/chatroom/${roomId}/exit`)
    }

 

그리고 채팅 위에서 구성한 채팅화면을 구성하여 소스를 완성한다.

src/pages/chat/ChatMessage.jsx

import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { chatRoomService } from '../../api/service/chat/ChatRoomService';

export default function ChatMessage() {

  const { roomId } = useParams(); // URL 파라미터에서 roomId를 가져온다.

  const [rooms, setRooms] = useState([]); // 채팅방 목록
  const [currentRoom, setCurrentRoom] = useState({}); // 현재 선택된 채팅방
  const [input, setInput] = useState(''); // 메시지 입력값
  const [currentUser] = useState('myUsername'); // 예시 사용자
  const [participants, setParticipants] = useState([]); // 채팅방 참여자 목록

  /**
   * 채팅방 목록을 조회한다.
   */
  async function loadRooms() {

    // 채팅방 입장처리 한다.
    await chatRoomService.enterChatRoom(roomId);

    // 내가 입장한 채팅방 목록을 조회한다.
    const res = await chatRoomService.selectMyChatRoom()
    const fetchedRooms = res.data.data;
    setRooms(fetchedRooms)

    // 현재 입장한 채팅방 정보
    const selected = fetchedRooms.find((r) => r.id.toString() == roomId )
    if( selected ) {
      
        setCurrentRoom(selected)

        // 채팅방에 입장 중인 참여자 정보를 조회한다.
        const participantsRes = await chatRoomService.selectParticipants(selected.id)
        if (participantsRes.data.success) {
          const names = participantsRes.data.data.map(u => u.nickname); // 또는 username
          setParticipants(names);
        }
    }
  }

  useEffect(() => {
    if (!roomId) return;

    // 채팅방 목록을 조회한다.
    loadRooms()

    // Component가 unmount 될 때 WebSocket을 닫는다.
    return () => socketRef.current.close();

  }, [roomId]);

  // 메시지 전송 처리
  const handleSend = () => {
    
  };

  // Enter 키로 메시지 전송
  const handleKeyDown = (e) => {
    if (e.key === 'Enter') handleSend();
  };

  return (
    <div className="flex h-screen">
      {/* 채팅방 목록 */}
      <div className="w-1/5 border-r bg-gray-100 p-4 overflow-y-auto">
        <h2 className="text-lg font-bold mb-4">채팅방</h2>
        <ul className="space-y-2">
          {rooms.map((room) => (
            <li key={room.id} className="p-2 rounded bg-white shadow">
              🗨️ {room.name}
            </li>
          ))}
        </ul>
      </div>

      {/* 메시지 영역 */}
      <div className="flex flex-col flex-1">
        <div className="p-4 border-b bg-white shadow font-bold text-xl">📢 {currentRoom.name}</div>
        <div className="flex-1 flex flex-col p-4 overflow-y-auto space-y-4 bg-white">
          {messages.map((msg, idx) => {
            const isMine = msg.sender === currentUser;
            return (
              <div key={idx} className={`flex ${isMine ? 'justify-end' : 'justify-start'}`}>
                <div className={`flex items-end gap-2 ${isMine ? 'flex-row-reverse' : ''}`}>
                  <img src={msg.avatar} alt={msg.sender} className="w-8 h-8 rounded-full border shadow" />
                  <div className="flex flex-col max-w-[66%]">
                    <p className={`text-xs text-gray-500 mb-1 ${isMine ? 'text-right' : 'text-left'}`}>{msg.sender}</p>
                    <div
                      className={`
                        px-4 py-2 rounded-xl shadow inline-block min-w-[10rem] break-words whitespace-normal 
                        ${isMine ? 'bg-blue-500 text-white self-end' : 'bg-gray-100 text-black self-start'}
                      `}
                    >
                      {msg.content}
                    </div>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
        <div className="p-4 border-t bg-white flex gap-2">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder="메시지를 입력하세요..."
            className="flex-1 border rounded px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-300"
          />
          <button onClick={handleSend} className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">전송</button>
        </div>
      </div>

      {/* 참여자 목록 */}
      <div className="w-1/5 border-l bg-gray-50 p-4 overflow-y-auto">
        <h2 className="text-lg font-bold mb-4">참여자</h2>
        <ul className="space-y-2">
          {participants.map((name, idx) => (
            <li key={idx} className="p-2 bg-white rounded shadow">👤 {name}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

채팅화면 처리 순서는 다음과 같다.

  1. 입장처리를 먼저 한다.
  2. 내가 참여한 채팅방 목록을 조회한다.
  3. 현재 채팅방 정보를 설정하고 채팅방에 참여한 참여자 목록과 채팅 메시지를 조회한다.
    여기서 채팅 메시지는 MongoDB에 저장하게 되는데 아직 구현하지는 않았다.
  4. WebSocket을 초기화한다.

 

우선 여기까지 끝.
WebSocket을 통해서 채팅을 주고 받는 백엔드는 다음에 추가하도록 한다.