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

2025. 5. 10. 11:48JAVA/Spring Boot

이제 채팅창 구성까지 끝났고 이제 메시지를 주고 받는 작업을 한다.

DTO 생성

ChatMessage.java

package com.company.common.database.document;

import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import java.time.LocalDateTime;

/**
 * MongoDB에 저장될 채팅 메시지 도큐먼트
 */
@Document(collection = "chat_messages")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatMessage {
    @Id
    private String id;

    private Long roomId;             // 채팅방 ID

    private String sender;           // 보낸 사람 닉네임

    private String content;          // 메시지 내용

    private String avatar;           // 아바타 이미지 URL

    private LocalDateTime timestamp; // 전송 시각
}

MongoDB를 사용하기 때문에 선언이 이전과 조금 다르다.

기존 DB를 사용할 경우 

@Table("chatrooms")
public class ChatRoom {
    ...
}

이렇게 @Table 어노테이션을 사용해서 Table을 지정하였다면

MongoDB는

@Document(collection = "chat_messages")

이와 같이 어노테이션을 사용해서 Document에 대한 설정을 해 준다.

 


Repository 생성

ChatMessageRepository.java

package com.company.chat.repository;

import org.springframework.data.mongodb.repository.ReactiveMongoRepository;

import com.company.common.database.document.ChatMessage;

import reactor.core.publisher.Flux;

public interface ChatMessageRepository extends ReactiveMongoRepository<ChatMessage, String> {

    // 특정 방의 메시지 리스트 (시간순)
    Flux<ChatMessage> findByRoomIdOrderByTimestampAsc(Long roomId);
}

Repository 또한 MongoDB를 사용하기 때문에 살짝 다르다.

기존 DB는 

public interface ChatRoomRepository extends ReactiveCrudRepository<ChatRoom, Long> {
	...
}

이와 같이 ReactiveCrudRepository Interface를 확장해서 사용했다면

MongoDB는 ReactiveMongoRepository Interface를 확장해서 사용한다.


 

Service 생성

ChatMessageService.java

package com.company.chat.service;

import org.springframework.stereotype.Service;

import com.company.chat.repository.ChatMessageRepository;
import com.company.common.database.document.ChatMessage;

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

/**
 * 채팅 메시지 저장/조회 서비스
 */
@Service
@RequiredArgsConstructor
public class ChatMessageService {

	private final ChatMessageRepository messageRepository;

    // 메시지 저장
    public Mono<ChatMessage> save(ChatMessage message) {
        return messageRepository.save(message);
    }

    // 메시지 조회 (방 ID 기준)
    public Flux<ChatMessage> findByRoomId(Long roomId) {
        return messageRepository.findByRoomIdOrderByTimestampAsc(roomId);
    }
}

채팅 메시지를 관리하는 Service로 메시지를 저장하고 조회하는 역할을 담당한다.

채팅 메시지를 저장하는 위치는 이전에 WebSocket 설정할 때 클라이언트가 메시지를 전송하면 receive하는 곳에서 메시지를 등록한다.

@Component
@Slf4j
@RequiredArgsConstructor
public class ChatWebSocketHandler implements WebSocketHandler {
    
    ...
    
    /**
     * 클라이언트와 WebSocket 연결이 수립되면 호출되는 메서드
     *
     * @param session 현재 연결된 WebSocket 세션 객체
     * @return Mono<Void> 비동기적으로 연결을 처리
     */
    @Override
    public Mono<Void> handle(WebSocketSession session) {
    
       ...

        // 클라이언트로부터 수신되는 메시지 스트림
        Flux<String> incoming = session.receive()
                                        .map(WebSocketMessage::getPayloadAsText).doOnNext(json -> {
                                            try {

                                                /*
                                                 * json 형태로 전송된 메시지 내용을 Object형태로 변환한다.
                                                 */
                                                ChatMessage msg = objectMapper.readValue(json, ChatMessage.class);
                                                msg.setTimestamp(LocalDateTime.now());
                                                msg.setRoomId(Long.parseLong(roomId));

                                                // MongoDB 저장
                                                chatMessageService.save(msg).subscribe();

                                                // 브로드캐스트
                                                EmitResult result = roomSink.tryEmitNext(json);
                                                log.info("Emit result: {}", result.name()); // 디버깅을 위해

                                            } catch (Exception e) {
                                                log.error("메시지 파싱 실패: {}", e.getMessage());
                                            }
                                        });
      ...
 }

 


 

Controller 생성

ChateMessageController.java

package com.company.chat.controller;

import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.company.chat.service.ChatMessageService;
import com.company.common.database.document.ChatMessage;
import com.company.common.dto.ApiResponse;

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

/**
 * 채팅 메시지 조회 컨트롤러
 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/chatroom")
public class ChatMessageController {

	private final ChatMessageService chatMessageService;

    /**
     * 특정 채팅방의 메시지 조회
     */
    @GetMapping("/{roomId}/messages")
    public Mono<ResponseEntity<ApiResponse<List<ChatMessage>>>> getMessagesByRoomId(@PathVariable Long roomId) {
    	return chatMessageService.findByRoomId(roomId)
				.collectList()
				.map(messages -> ResponseEntity.ok(new ApiResponse<>(true, "메시지 조회 성공", messages)) );
    }

}

채팅방에서 메시지를 조회 해 가는 부분이다.

 

이렇게 까지가 백엔드 끝이다. 


 

다음은 프론트엔드 처리한다.

우선 service를 먼저 생성한다.

import axiosInstance from "../../instance/axiosInterceptor";

/**
 * 채팅메시지 관리 Service
 */
class ChateMessageService {

    /**
     * 특정 채팅방의 메시지 목록 조회
     * @param {number} roomId
     * @returns {Promise<Response<ChatMessage[]>>}
     */
    async selectMessagesByRoomId(roomId) {
        return await axiosInstance.get(`/chatroom/${roomId}/messages`)
    }

}

export const chateMessageService = new ChateMessageService();

이제 메시지를 주고 받는 부분을 

이전에 작업한 ChatMessage.jsx 파일에 추가하도록 한다.

import { chateMessageService } from '../../api/service/chat/chateMessageService';

export default function ChatMessage() {
  ...
  const [messages, setMessages] = useState([]); // 채팅 메시지 목록
  const [input, setInput] = useState(''); // 메시지 입력값
  const socketRef = useRef(null); // WebSocket 참조
  const messageEndRef = useRef(); // 메시지 목록 끝 참조
    
  ...
    
  /**
   * WebSocket 연결을 초기화한다.
   */
  async function initWebSocket() {
    // WebSocket 연결을 초기화한다.
    const socket = new WebSocket(`ws://localhost:8080/ws/chat/${roomId}`);
    socketRef.current = socket;
    socket.onopen = () => {
      console.log('WebSocket 연결 성공');
    };

    // WebSocket 메시지 전달 받은 경우
    socket.onmessage = (e) => {
      const msg = JSON.parse(e.data);
      setMessages((prev) => [...prev, msg]);
    };
  }
 
  useEffect(() => {
    if (!roomId) return;
 
    // WebSocket 연결을 초기화한다.
    initWebSocket()

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

  }, [roomId]);
    

  // 메시지를 전송한다.
  const handleSend = () => {
    if (input.trim()) {
      const msg = {
        sender: currentUser,
        content: input,
        avatar: '/avatar.png',
      };

      socketRef.current.send(JSON.stringify(msg));
      setInput('');
    }
  };

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

  // 메시지 목록이 변경될 때마다 스크롤을 맨 아래로 이동
  useEffect(() => {
    messageEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

추가된 부분이다.

전체 소스는 다음과 같다.

src/pages/chat/ChatMessage.jsx

 

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

export default function ChatMessage() {

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

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

  /**
   * 채팅방 목록을 조회한다.
   */
  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);
        }

        // 채팅방에 있는 메시지 목록을 조회한다.
        const messageRes = await chateMessageService.selectMessagesByRoomId(selected.id);
        if( messageRes.data.success ) {
          setMessages(messageRes.data.data)
        }
    }
  }

  /**
   * WebSocket 연결을 초기화한다.
   */
  async function initWebSocket() {
    // WebSocket 연결을 초기화한다.
    const socket = new WebSocket(`ws://localhost:8080/ws/chat/${roomId}`);
    socketRef.current = socket;
    socket.onopen = () => {
      console.log('WebSocket 연결 성공');
    };

    // WebSocket 메시지 전달 받은 경우
    socket.onmessage = (e) => {
      const msg = JSON.parse(e.data);
      setMessages((prev) => [...prev, msg]);
    };

  }

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

    // WebSocket 연결을 초기화한다.
    initWebSocket()

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

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

  }, [roomId]);

  // 메시지를 전송한다.
  const handleSend = () => {
    if (input.trim()) {
      const msg = {
        sender: currentUser,
        content: input,
        avatar: '/avatar.png',
      };

      socketRef.current.send(JSON.stringify(msg));
      setInput('');
    }
  };

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

  // 메시지 목록이 변경될 때마다 스크롤을 맨 아래로 이동
  useEffect(() => {
    messageEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  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 ref={messageEndRef} />
        </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>
  );
}

 

우선 여기까지로 WebFlux를 이용해서 채팅방을 생성하고 채팅을 하는 방법에 대해 나열했다.

WebFlux, 비동기, Non-Block, Reactive 프로그래밍 등에 대해 공부한답시고 만들었는데. 

참 어렵기도 하고 난해하기도 하고, 그리고 무엇보다 너무 빠르다라는 생각이 자꾸든다.

이제 나이도 먹어서.. 따라가기 힘들다는..ㅋㅋ

근데 재미난걸!!!


참고 GIT

백엔드 : https://github.com/kamsi76/webflux-chat-backend

 

GitHub - kamsi76/webflux-chat-backend

Contribute to kamsi76/webflux-chat-backend development by creating an account on GitHub.

github.com

프론트엔드 : https://github.com/kamsi76/webflux-chat-frontend

 

GitHub - kamsi76/webflux-chat-frontend

Contribute to kamsi76/webflux-chat-frontend development by creating an account on GitHub.

github.com