실시간 통신이 필요한 채팅 서비스를 구현할 때, 데이터베이스 선택과 통신 프로토콜 설계는 가장 중요한 고민거리입니다. 이번 포스팅에서는 **MariaDB(JPA)**의 안정성과 MongoDB의 유연함을 결합한 하이브리드 아키텍처를 바탕으로, **WebSocket(STOMP)**을 이용해 실시간 채팅을 구현한 과정을 공유합니다.

1. 하이브리드 데이터베이스 설계 (JPA + MongoDB)
모든 데이터를 한 곳에 담지 않고, 성격에 따라 데이터베이스를 분리하여 효율을 높였습니다.
- MariaDB (JPA): 채팅방 이름, 생성일, 최대 참여 인원 등 정적인 메타데이터를 저장합니다. 데이터 일관성이 중요한 '방 관리'에 적합합니다.
- MongoDB: 방대하게 쌓이는 **채팅 메시지(비정형 데이터)**를 저장합니다. 스키마가 자유롭고 쓰기 속도가 빨라 대량의 메시지 로그를 처리하기에 최적입니다.
2. 실시간 통신 흐름 (WebSocket & STOMP)
통신 프로토콜로는 STOMP를 사용했습니다. 단순 WebSocket보다 Pub/Sub 구조를 구현하기 쉽고 메시지 브로커를 통해 관리가 용이하기 때문입니다.
🚩 통신 시나리오
- 입장: 사용자가 방에 진입하면 해당 방의 토픽(/sub/chat/room/{roomId})을 구독합니다.
- 발행: 메시지를 입력하면 특정 경로(/pub/message)로 데이터를 송신합니다.
- 처리 및 저장: 서버는 메시지를 받아 MongoDB에 저장한 뒤, 해당 방 구독자들에게 실시간으로 뿌려줍니다.
3. 핵심 코드 구현
Backend: 메시지 저장 및 전송 (ChatController)
@Controller
@RequiredArgsConstructor
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
private final ChatService chatService;
@MessageMapping("/message")
public void message(ChatMessage message) {
// 메시지 타입에 따른 비즈니스 로직 처리 (입장 알림 등)
if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
message.setMessage(message.getSenderName() + "님이 입장하셨습니다.");
}
// 1. MongoDB에 비정형 메시지 데이터 저장
chatService.saveMessage(message);
// 2. 해당 방을 구독 중인 클라이언트들에게 메시지 브로드캐스트
messagingTemplate.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
}
}
Backend: 데이터 무결성을 고려한 삭제 (ChatRoomService)
방을 삭제할 때 RDB 데이터만 지우면 MongoDB에 '유령 데이터'가 남습니다. 이를 방지하기 위해 연쇄 삭제 로직을 구현했습니다.
@Transactional
public void deleteRoom(String roomId) {
// 1. MariaDB에서 방 정보 삭제
ChatRoom room = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new RuntimeException("방 없음"));
chatRoomRepository.delete(room);
// 2. MongoDB에서 해당 방의 모든 메시지 연쇄 삭제 (데이터 무결성 보장)
chatMessageRepository.deleteByRoomId(roomId);
}
4. 프론트엔드 연동 (React & MUI)
React에서는 SockJS와 stompjs를 활용해 연결을 관리하고, MUI의 Stack과 Box를 이용해 카카오톡 스타일의 UI를 구성했습니다.
UX 디테일: 자동 스크롤 훅
채팅에서 가장 중요한 요소 중 하나는 새 메시지가 올 때 화면이 자동으로 아래로 내려가는 것입니다.
const scrollRef = useRef();
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [contents]); // 메시지 리스트(contents)가 변할 때마다 실행
5. 트러블슈팅 (Troubleshooting)
📌 한글 입력 시 엔터 중복 전송 문제
한글은 자음과 모음이 조합되는 과정(IME)이 있어, 엔터를 칠 때 마지막 글자가 한 번 더 전송되는 현상이 있었습니다. isComposing 속성을 체크하여 해결했습니다.
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return; // 조합 중일 때는 전송 방지
if (e.key === 'Enter') handleSendMessage();
}}
📌 Spring Security 403 에러
WebSocket 연결 시 Handshake 단계에서 JWT 인증 필터에 걸려 연결이 차단되는 이슈가 있었습니다. SecurityConfig에서 WebSocket 엔드포인트(/ws-chat/**)를 permitAll()에 추가하여 해결했습니다.
🌐 5. 시스템 통신 흐름 상세 (The Lifecycle)
1단계: 초기화 및 방 목록 로드 (REST API)
사용자가 OpenChatPage에 접속하면 가장 먼저 **JPA(MariaDB)**와의 통신이 발생합니다.
- React (loadRooms): 페이지 로드 시 fetchAllChatRooms()를 호출합니다.
- Spring Boot (ChatRoomController): MariaDB에서 저장된 모든 ChatRoom 엔티티를 조회하여 MultipleResult 구조로 응답합니다.
- React (setRooms): 응답받은 res.datas를 상태에 저장하고, 화면 왼쪽 리스트에 방 목록을 렌더링합니다.
2단계: 방 입장 및 과거 내역 복구 (REST API + WebSocket)
사용자가 특정 방을 클릭하거나 첫 번째 방이 자동으로 선택되면 두 가지 일이 동시에 일어납니다.
- 과거 내역 복구: fetchChatHistory(roomId) API를 통해 MongoDB에 저장된 해당 방의 모든 이전 메시지를 시간순으로 가져와 setContents에 채웁니다. 덕분에 새로고침해도 대화가 유지됩니다.
- WebSocket 핸드셰이크: new SockJS('/ws-chat')를 통해 서버와 실시간 통로를 엽니다. 이때 Spring Security가 허용한 /ws-chat/** 경로를 통해 연결이 확정됩니다.
3단계: 실시간 구독 및 메시지 발행 (STOMP Pub/Sub)
연결이 성공하면 본격적인 Pub/Sub(발행/구독) 모델이 동작합니다.
- 구독 (Subscribe): React는 /sub/chat/room/{roomId} 경로를 구독합니다. 이제 서버가 이 경로로 쏘는 모든 메시지는 이 유저의 화면에 즉시 나타납니다.
- 발행 (Publish): 유저가 메시지를 입력하고 전송을 누르면 /pub/message 경로로 JSON 데이터를 보냅니다.
- 서버 처리 (ChatController):
- 서버는 메시지를 수신하여 누가 보냈는지, 타입이 무엇인지 확인합니다.
- MongoDB 저장: 수신된 메시지를 MongoDB에 영구 저장합니다.
- 브로드캐스트: 저장된 메시지를 해당 방의 구독 경로인 /sub/chat/room/{roomId}로 다시 쏴줍니다.
📊 2. 데이터 관리 전략 (Hybrid Architecture)
| 구분 | 관리 데이터 | 저장소 (Storage) | 특징 |
| 정적 데이터 | 방 ID, 방 제목, 인원수 | MariaDB (JPA) | 트랜잭션 보장, 정형화된 관계 관리 |
| 동적 데이터 | 채팅 메시지, 시간, 보낸이 | MongoDB | 비정형 데이터의 빠른 쓰기, 유연한 확장성 |
🛠 3. 주요 기술적 디테일 (Troubleshooting Points)
- 한글 입력 최적화 (IME)
- e.nativeEvent.isComposing을 체크하여 한글 조합 중 엔터를 쳤을 때 메시지가 두 번 전송되는 브라우저 고유 문제를 해결했습니다.
- 데이터 무결성 (Cascade Delete)
- 방 삭제 시 ChatRoomService에서 JPA 데이터만 지우는 게 아니라, MongoDB의 메시지까지 deleteByRoomId로 한꺼번에 날려버려 유령 데이터를 방지했습니다.
- 사용자 경험 (UX)
- scrollRef와 useEffect를 조합하여 새 메시지가 오면 화면이 가장 아래로 자동으로 내려가는 'Auto-scroll' 기능을 구현했습니다.
- 표준화된 응답 (Result Wrapper)
- MultipleResult와 SingleResult를 사용하여 프로젝트 전체 API 규격을 통일하고, 프론트엔드에서 일관된 방식으로 데이터를 처리(res.success, res.datas)합니다.
6. 마치며
이번 프로젝트를 통해 RDB의 엄격함과 NoSQL의 유연함을 조화롭게 사용하는 법을 익혔습니다. 실시간 통신에서 데이터가 쌓이는 지점과 사용자에게 보여지는 지점 사이의 무결성을 맞추는 과정이 가장 흥미로웠습니다.
포스팅이 도움이 되셨다면 댓글과 공감 부탁드립니다! 😊
'Spring & SpringBoot' 카테고리의 다른 글
| [Vite/React + Spring Boot] 카카오 소셜 로그인 완벽 연동 가이드 (0) | 2025.12.11 |
|---|---|
| 💡 Spring Boot에서 @RestControllerAdvice는 어디까지 적용될까? (0) | 2025.07.25 |
| GitHub Actions + Self-hosted Runner로 구성한 MSA 자동 배포 시스템 (2) | 2025.07.22 |
| Spring Cloud Gateway와 MSA 환경에서 내부 서비스 간 인증 처리 전략 (0) | 2025.07.18 |
| Spring Boot MSA 구조에서 Auth-Service 개발하며 겪은 실제 문제와 해결 방법 (1) | 2025.07.17 |