2025. 5. 10. 11:48ㆍJAVA/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
'JAVA > Spring Boot' 카테고리의 다른 글
Spring WebFlux를 이용한 chat 프로그램 - 다듬기 (0) | 2025.05.12 |
---|---|
Spring WebFlux를 이용한 chat 프로그램 - 채팅(1) (0) | 2025.05.09 |
Spring WebFlux를 이용한 chat 프로그램 - 채팅방생성(2) (0) | 2025.05.09 |
Spring WebFlux를 이용한 chat 프로그램 - 채팅방생성(1) (0) | 2025.05.08 |
Spring WebFlux를 이용한 chat 프로그램 - WebSocket 설정 (0) | 2025.05.08 |