[Spring] 1 대 1 실시간 채팅 구현하기 - Stomp, MongoDB, Redis

2026. 1. 24. 19:08·Backend/Spring

1. 배경 및 목표

1) 배경

사용자 간 빠른 소통을 위한 실시간 채팅의 필요성이 제시됐습니다.


이전에 실시간 채팅을 구현해본 경험이 있습니다.

 

[WebSocket] Spring, React, Stomp로 실시간 채팅, 저장 구현하기

팀원들과 프로젝트를 진행하였고 의미 있는 내용들을 포스팅 해보려고 합니다.프로젝트는 다음과 같이 진행되었습니다.Java 17Spring Boot 3.2.0JPAGradleReact저에게는 자그마한 꿈이 있었습니다. 바로

5g-0.tistory.com

 

이 구조를 그대로 실서비스에 적용하기에는 사용성이 떨어질 수 있다고 판단했습니다. 이유는 다음과 같습니다.

  • RDB로 채팅 메시지를 저장/조회
    • 메시지가 쌓일수록 조회 성능 저하 및 부하 증가
  • 서버 증축(Scale-out) 계획
    • 현재는 단일서버로 서버 증축에 관한 이야기가 나오고 있음
    • 여러 서버로 늘어날 때 서버 간 메시지 동기화가 어려움
    • WebSocket 연결은 서버 로컬에 유지됨, 서버가 여러 대가 되면 "서버 간 전달"이 필요

즉, 이전 구현은 단일 서버 환경에서는 동작하지만, 운영 환경을 고려하면 한계가 명확했습니다.

이번 구현에서는 단순히 채팅 기능을 만드는 것이 아니라, 운영 환경에서도 안정적으로 동작할 수 있는 구조를 목표로 했습니다.

2) 목표

이번 채팅 시스템은 카카오톡의 1:1 채팅 구조를 참고하여 설계했습니다.
읽음 처리, 채팅방 나가기 및 재입장 등의 기능을 구현했지만, 본 글에서는 핵심적인 구조와 설계에 집중하려고 합니다.


전체적인 설계는 다음과 같습니다.

  • 채팅 메시지는 MongoDB에 저장
  • 채팅방/참여자/유저 정보는 RDB에서 관리
  • 서버 간 메시지 전파를 위해 Redis Pub/Sub 적용

채팅 메시지는 수정이 거의 없고, 시간순 조회가 대부분이기 때문에 정규화된 RDB보다는 문서 기반의 MongoDB가 더 적합하다고 판단했습니다. 채팅방, 참여자, 유저 정보는 정합성이 중요하기 때문에 RDB에서 관리하고자 했습니다.


설계 아키텍처는 다음과 같습니다.




2. 개념

이번 채팅 시스템에서 사용한 핵심 기술과 그 역할을 간단히 정리해보겠습니다.

1) WebSocket & Stomp

WebSocket은 클라이언트와 서버 간 지속적인 연결을 유지할 수 있는 통신 방식입니다.

WebSocket: 단일 TCP 연결로 동시 양방향통신 채널을 제공하는 컴퓨터 통신 프로토콜


일반적인 HTTP 통신은

요청(Request) -> 응답(Response) 구조이기 때문에 실시간 데이터 전달에는 적합하지 않습니다.


반면, WebSocket은 한 번 연결되면,

  • 서버 -> 클라이언트
  • 클라이언트 -> 서버

양방향 통신이 가능하기 때문에 채팅, 알림, 실시간 데이터 처리에 적합하다는 특징이 있습니다.

 

 

WebSocket만 사용할 경우 다음과 같은 문제가 있습니다.

  • 메시지 형식을 직접 정의해야 함
  • 메시지 라우팅 로직이 복잡해짐
  • 채팅방 개념을 직접 구현해야 함

이를 해결하기 위해 STOMP(Simple Text Oriented Messaging Protocol) 를 함께 사용했습니다.


STOMP는 텍스트 기반 메시징 프로토콜로 Sub(구독)/Pub(발행) 구조를 통해 메시지 형식과 규칙을 정의하여, 클라이언트와 서버 간 실시간 통신을 효율적으로 구현하는데 사용됩니다.

STOMP(Simple Text Orieted Messaging Protocol): 웹소켓(WebSocket) 등 양방향 네트워크 위에서 동작하는, 헤더와 바디로 구성된 텍스트 기반 메시징 프로토콜

  • /pub/** -> 서버로 메시지 전송
  • /sub/** -> 서버에서 메시지 구독

이번 구현에서는 STOMP를 사용해 채팅방 단위 구독(/sub/room/{roomId})을 만들고, 서버에서 해당 채팅방으로 이벤트를 발행하는 구조로 구성했습니다.


2) NoSQL & MongoDB

채팅 메시지는 일반적인 데이터와 비교하여 다음과 같은 특성을 가집니다.

  • 쓰기(INSERT) 빈도가 매우 높음
  • 수정이 거의 없음
  • 시간순 조회가 대부분
  • 데이터 양이 빠르게 증가
  • 단순 텍스트 뿐만 아니라 사진, 영상 등 다양한 형태로 구조가 바뀔 수 있음

이러한 특성은 전형적인 로그성 데이터에 가깝습니다.

RDB로도 구현은 가능하지만, 대규모 데이터의 빠른 읽기/쓰기 작업, 비정형데이터 저장에 강점을 가진 NoSQL이 적절하다고 판단하였습니다.

NoSQL: 고정된 테이블 스키마 없이 문서, 키-값, 그래프 등 유연한 데이터 모델을 사용하여 대규모 비정형 데이터를 빠르게 처리하는 비관계형 데이터베이스


NoSQL은 크게 다음과 같이 나뉩니다.

  • Key-Value (Redis, DynamoDB)
  • Document (MongoDB)
  • Column Family (Cassandra, HBase)
  • Graph (Neo4j)

Document 기반 DB(MongoDB) 가 가장 적합하다고 판단했습니다.

MongoDB의 장점은 다음과 같습니다.

특징 설명
빠른 쓰기 채팅 메시지 특성에 적합
유연한 스키마 메시지 구조 변경 용이
ObjectId 커서 기반 페이징에 적합
대용량 처리 로그성 데이터에 최적

 

특히 MongoDB의 ObjectId는 시간순 정렬이 가능하기 때문에 커서 기반 페이징 구현에도 매우 적합했습니다.



3) Redis Pub/Sub

WebSocket은 기본적으로 로컬 기반 연결입니다.

서버가 여러대일 경우 다음과 같은 문제가 발생합니다.

A서버에 연결된 사용자가 보낸 메시지를
B 서버에 연결된 사용자는 받을 수 없다

 

이 문제를 해결하기 위해 Redis Pub/Sub를 사용했습니다.


Redis는 이 구조에서 다음과 같은 역할을 담당합니다.

  • 메시지 이벤트 전달
  • 서버 간 브로드캐스트
  • 실시간 동기화

각 서버는 Redis 채널을 구독하고 있으며, 어느 서버에서 메시지가 발행되든 모든 서버가 이를 수신합니다.


다른 선택지로 Kafka, Rabbit MQ 등이 있었지만,

이미 Redis를 캐시로 사용하고 있었고 상대적으로 구현 난이도가 낮다는 장점이 있었기 때문에

메시지 브로커로 Redis를 선택하였습니다.



3. 구현

1) 채팅 시스템 진입, 연결 / 인증

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompAuthInterceptor stompAuthInterceptor;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub");
        registry.setApplicationDestinationPrefixes("/pub");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompAuthInterceptor);
    }
}

 

이 클래스는 채팅 시스템의 진입점이라고 볼 수 있습니다.

먼저 registerStompEndPoints에서 클라이언트가 연결을 맺는 엔드포인트(/ws)를 등록합니다.


configureMessageBroker에서 sub, pub 설정을 해줍니다. /pub/**로 들어오는 메시지는 @MessageMapping으로 라우팅되고, 서버가 클라이언트로 내려주는 메시지는 /sub/** 경로로 전달됩니다.


configureClientInboundChannel는 STOMP 메시지가 서버로 들어오기전에 가로채기 위한 설정입니다.

주입한 인터셉터는

  • WebSocket CONNECT 시 JWT 인증
  • SUBSCRIBE 시 채팅방 입장 처리

등을 수행합니다.


예시로 인터셉터 클래스의 연결, 구독 부분을 가져와봤습니다.

case CONNECT -> {
    String token = accessor.getFirstNativeHeader("Authorization");
    AuthTokenHolder holder = jwtProvider.getAuthTokenHolder(token);
    Authentication authentication = jwtProvider.getAuthentication(holder.token());
    accessor.setUser(authentication);
}

case SUBSCRIBE -> {
    String destination = accessor.getDestination();
    if (destination != null && destination.startsWith("/sub/room/")) {
        String roomId = destination.replace("/sub/room/", "");
        redisRepository.setValues("chat:active" + accountId, roomId, 60);
    }
}

 

WebSocket 연결은 일반 HTTP 요청과 다르게 연결 이후에도 계속 메시지가 오갑니다.

CONNECT 시점에 인증을 완료해두면 이후 메시지 처리에서 Principal 기반으로 사용자를 안전하게 식별할 수 있습니다.


SUBSCRIBE 시점에 Redis에 chat:active{accountId} = roomId를 TTL과 함께 저장하여, 사용자가 현재 어떤 채팅방을 보고 있는지 추적합니다.
이 값은 이후 메시지 수신 시 읽음 처리 vs 알림 분기를 결정하는 근거로 사용됩니다.
또한 연결 종료(UNSUBSCRIBE/DISCONNECT) 시에는 값을 삭제하도록 구성했습니다.



2) 메시지 전송

@Controller
@RequiredArgsConstructor
public class ChatController {

    private final ChatService chatService;

    @MessageMapping("/chat/send")
    public void sendChatMessage(@Payload ChatMessageRequest chatMessageRequest, Principal principal) {
        UsernamePasswordAuthenticationToken user = (UsernamePasswordAuthenticationToken) principal;
        Account sender = (Account) user.getPrincipal();
        chatService.sendMessage(sender, chatMessageRequest);
    }
}

 

채팅 메시지의 진입점인 ChatController입니다.

이 컨트롤러는 WebSocket 메시지를 수신하고 인증된 사용자 정보를 추출합니다.

 

@Transactional
public void sendMessage(Account sender, ChatMessageRequest chatMessageRequest) {
    ChatRoom chatRoom = chatRoomRepository.findById(chatMessageRequest.getChatRoomId()).orElseThrow(ChatRoomNotFoundException::new);
    ChatMember chatMember = chatMemberRepository.findByChatRoomIdAndAccount(chatMessageRequest.getChatRoomId(), sender)
            .orElseThrow(ChatMemberNotFoundException::new);

    if (chatMember.getLeftAt() != null) {
        chatMember.rejoin();
    }

    ChatMessage chatMessage = ChatMessage.builder()
            .chatRoomId(chatMessageRequest.getChatRoomId())
            .senderId(sender.getId())
            .chatType(chatMessageRequest.getChatType())
            .content(chatMessageRequest.getContent())
            .createdAt(LocalDateTime.now())
            .build();

    chatMessageRepository.save(chatMessage);
    chatMember.updateLastReadMessageId(chatMessage.getId().toHexString());
    chatMessagePublisher.publishChat(new ChatMessageDto(chatMessage));
}

 

메시지를 저장하기 전에 다음을 검증합니다.

  • 채팅방이 실제로 존재하는지
  • 사용자가 해당 채팅방의 참여자인지

이를 통해 잘못된 요청이나 권한없는 접근을 방지합니다.


사용자가 채팅방을 나갔다가 다시 메시지를 보내는 경우를 고려하여 leftAt 값이 존재하면 자동으로 재입장 처리를 합니다.
(카카오톡 참고)


채팅 메시지는 MongoDB에 저장되고, 마지막 읽은 메시지를 확인하는 lastReadMessageId를 갱신합니다.

이후 Redis Pub/Sub으로 메시지를 전파합니다.

public void publishChat(ChatMessageDto chatMessageDto) {
    String channel = "chat.room." + chatMessageDto.getChatRoomId();
    redisTemplate.convertAndSend(channel, chatMessageDto);
}



3) 메시지 수신

@Transactional
public void onMessage(ChatMessageDto message) {
    Long chatRoomId = message.getChatRoomId();
    Long senderId = message.getSenderId();

    messagingTemplate.convertAndSend("/sub/room/" + message.getChatRoomId(), message);

    ChatMember receiver = chatMemberRepository.findReceiverChatMemberBySenderId(chatRoomId, senderId)
            .orElseThrow(ChatMemberNotFoundException::new);
    Long receiverId = receiver.getAccount().getId();

    if (receiver.getLeftAt() != null) {
        receiver.rejoin();
    }

    String activeRoomId = redisRepository.getValues(PREFIX + receiverId);
    boolean isReceiverInThisRoom = activeRoomId != null && activeRoomId.equals(chatRoomId.toString());

    if (isReceiverInThisRoom) {
        // 읽음 이벤트
    } else {
        // push 알림
    }
}

 

발행된 Redis Pub 메시지를 수신하는 부분입니다.

메시지 발행자와 채팅방 정보를 통해 상대방을 특정합니다.


만약 상대방이 채팅방을 나간 상태라면 채팅방에 다시 입장시킵니다.
(카카오톡 참고)


redis에 저장된 값을 통해 현재 상대방이 채팅방을 보고있는지 확인합니다.

만약 상대방이 채팅방을 보고 있다면 읽음 이벤트를 발행하고, 아니라면 push 알림을 보낼 수 있도록 분기처리합니다.



4) 채팅 메시지 설계

@Document(collection = "chat_message")
@CompoundIndex(
        name = "room_created_idx",
        def = "{'chatRoomId':1, 'createdAt':-1}"
)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {

    @Id
    private ObjectId id;

    @Schema(description = "채팅방")
    private Long chatRoomId;

    @Schema(description = "보낸 사람")
    private Long senderId;

    @Schema(description = "메시지 타입")
    private ChatType chatType;

    @Schema(description = "내용")
    private String content;

    @Schema(description = "보낸 시점")
    private LocalDateTime createdAt;
}

 

채팅 메시지는 항상 채팅방 단위 + 최신순 조회가 필요합니다.

따라서 다음 기준으로 복합 인덱스를 설정했습니다.

  • chatRoomId: 채팅방 필터링
  • createdAt: 최신 메시지부터 조회

이를 통해 채팅방 진입 시 최신 메시지를 빠르게 조회할 수 있도록 하였습니다.


MongoDB에는 채팅 메시지만을 저장했습니다.

다른 채팅방 정보나 참여자 정보와 같은 데이터는 정합성이 중요하고, 사용자 정보등과의 Join이 필수적이기 때문에 RDB에서 관리하는것이 더 적합하다고 판단했습니다.


채팅방 목록 조회 시에는
RDB에서 채팅방 및 참여자 정보를 조회하고,
MongoDB에서 마지막 메시지 정보를 조회한 뒤
Service 단에서 이를 조합하여 Response DTO를 구성하는 방식으로 구현했다.

이를 통해 정합성과 조회 성능을 확보할 수 있었습니다.

 


목록조회, 채팅방 메시지 조회 등은 사실 특별할게 없는 조회라고 생각하여 따로 넣지 않았습니다.

2년? 만에 다시 실시간 채팅을 건드려보니 이전 구현에 비해 많이 성장함을 느낄 수 있어서 좋았습니다.

'Backend > Spring' 카테고리의 다른 글

[Spring] Redis Sorted Set, ZSet 을 이용한 매칭 시스템 구현하기  (0) 2025.10.15
[Spring] 로컬, AWS LightSail에서 AWS parameter store로 환경변수 관리하기  (0) 2025.07.19
[Spring] Spring AOP의 동작원리, JDK Dynamic Proxy와 CGLIB  (1) 2025.04.28
[Spring] Apache.commons.exec 사용, 외부 명령어 실행 API 만들기, Java에서 Shell 사용  (0) 2024.05.26
[WebSocket] Spring, React, Stomp로 실시간 채팅, 저장 구현하기  (12) 2024.02.19
'Backend/Spring' 카테고리의 다른 글
  • [Spring] Redis Sorted Set, ZSet 을 이용한 매칭 시스템 구현하기
  • [Spring] 로컬, AWS LightSail에서 AWS parameter store로 환경변수 관리하기
  • [Spring] Spring AOP의 동작원리, JDK Dynamic Proxy와 CGLIB
  • [Spring] Apache.commons.exec 사용, 외부 명령어 실행 API 만들기, Java에서 Shell 사용
단군왕건영
단군왕건영
널리 세상을 이롭게 하고 싶은 개발자
  • 단군왕건영
    홍익인간 개발자
    단군왕건영
  • 전체
    오늘
    어제
    • 분류 전체보기 (81) N
      • TroubleShooting (14)
      • Backend (11) N
        • Java (2)
        • Spring (7) N
        • JPA (2)
      • DB (1)
      • Algorithm (7)
        • 백준 (4)
      • Frontend (0)
        • React (0)
      • Infra (3)
      • CS (37)
        • 컴퓨터구조 (25)
        • 네트워크 (12)
      • Git (3)
      • Mac (2)
      • 회고 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • GitHub
  • 공지사항

  • 인기 글

  • 태그

    MariaDB
    백준
    docker
    컴퓨터 구조
    네트워크
    springboot
    Jenkins
    java
    spring
    컴퓨터구조
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
단군왕건영
[Spring] 1 대 1 실시간 채팅 구현하기 - Stomp, MongoDB, Redis
상단으로

티스토리툴바