채팅 서비스, 왜 Pub/Sub인가? Redis가 완성시키는 Spring boot 기반 채팅 서비스 공부 기록

Daniel Kim·2025년 6월 29일

안녕하세요! 오늘은 실시간 채팅 서비스를 구축하면서 마주했던 아키텍처 설계의 깊은 고민들과, 이를 해결하기 위해 Redis Pub/SubSpring Boot WebSocket을 어떻게 결합했는지에 대한 여정을 공유하려 합니다. 단순한 기능 구현을 넘어, 왜 이런 기술 스택을 선택했고 어떻게 유기적으로 연결했는지에 대한 상세한 삽질 기록들을 담아보겠습니다.

나중에 저 스스로도 까먹지 않도록, 독자도 한 번에 이해할 수 있도록, 최대한 자세히 작성해 보았어요. 구조부터 보자면 다음과 같습니다.


> 1. 서론: 왜 실시간 채팅인가? 그리고 아키텍처의 중요성

팀플 도중 '채팅 서비스'를 구현하게 되는 과제에 직면하면서, 처음에는 단순히 WebSocket을 통해 서로 다른 두 사용자를 연결하고, '채팅을 보내면 DB에 자동으로 저장' -> 'DB에서는 채팅 기록을 읽어 채팅창에 최신화'라는 단순한 구조를 떠올렸어요. 하지만 카카오톡, 비트윈, 라인, 어떤 채팅 프로그램도 새로고침을 해야 채팅이 최신화되는 식으로는 구현하지 않죠. 단순히 메세지를 주고받는 게 아니라, 상대방과 접속된 상태에서 실시간으로 대화를 나눌 수 있는 기능은 어떻게 구현해야 할까? 이런 고민 속에 개발을 시작하게 되었습니다.

처음 이 주제를 마주했을 때, 가장 먼저 떠오른 질문은 다음과 같았습니다.

  • 어떤 기술을 써야 실시간 통신이 가능할까?
  • 사용자가 많아지면 어떻게 감당하지? (확장성)
  • 메시지 유실 없이 안정적으로 전달하려면?
  • 이전 채팅 기록은 어떻게 보여주지?
  • 프론트엔드와 백엔드는 어떻게 연동하지?

잘못된 설계는 나중에 큰 기술 부채로 돌아올 수 있기에, 초기 단계부터 꼼꼼하게 따져보고자 했습니다.


> 2. 실시간 통신 기술 선택: WebSocket이 답이었다

가장 먼저 실시간 통신을 위한 기반 기술을 선택해야 했습니다. 구글링해보니 여러가지 기술이 나왔습니다. 당연히 선택지는 하나(WebSocket)이었지만, 그래도 여러 선택지를 비교해보고 싶었어요. 선택지들은 다음과 같았습니다.

  1. Polling (폴링): 클라이언트가 주기적으로 서버에 새 메시지가 있는지 요청하는 방식.
  • 고민: 구현은 간단하지만, 짧은 주기로 요청하면 서버 부하가 커지고, 긴 주기는 실시간성이 떨어집니다. 비효율적이라고 판단했습니다.
  1. Long Polling (롱 폴링): 클라이언트가 요청하면 서버는 새 메시지가 도착할 때까지 연결을 유지하다가 메시지가 오면 응답하고 연결을 끊는 방식.
  • 고민: 폴링보다는 효율적이지만, 여전히 새로운 요청을 계속 보내야 하고, 연결 유지를 위한 서버 자원 소모가 있을 수 있습니다. 양방향 통신에 한계가 명확합니다.
  1. Server-Sent Events (SSE): 서버가 클라이언트로 단방향 요청을 지속적으로 보내는 방식.
  • 고민: 서버에서 클라이언트로의 단방향 실시간 스트리밍에는 적합하지만, 채팅처럼 클라이언트도 서버로 메시지를 보내야 하는 양방향 통신에는 적합하지 않았습니다.
  1. WebSocket (웹소켓): 클라이언트와 서버 사이에 지속적인 양방향 통신 채널을 여는 방식.
  • 고민: 한 번의 핸드셰이크 이후 HTTP 연결을 업그레이드하여 전이중 통신 채널을 만듭니다. 불필요한 HTTP 오버헤드가 없어 가장 효율적이고, 진정한 실시간 양방향 통신에 최적화되어 있었습니다.

결론적으로, 양방향 실시간 통신이라는 채팅 서비스의 핵심 요구사항을 충족시키기 위해 WebSocket을 선택했습니다.

2.1. WebSocket의 한계와 SockJS의 도입

이 부분에 대해서 아래의 글을 참조했어요:

https://yoo-dev.tistory.com/51

WebSocket은 훌륭하지만, 모든 브라우저나 네트워크 환경에서 완벽하게 지원되는 것은 아닙니다. 특히나 웹소켓은 HTML5 이후에 나왔기 때문에, 이를 지원하지 않는 구형 브라우저나 프록시/방화벽 환경에서는 웹소켓 연결이 실패할 수 있습니다.

이러한 호환성 문제를 해결하기 위해 SockJS 라이브러리 도입을 고민했습니다. SockJS는 웹소켓이 지원되지 않을 경우, 자동으로 HTTP 스트리밍, 롱 폴링 등 다른 HTTP 기반 기술을 사용하여 웹소켓과 유사한 통신 환경을 제공하는 JavaScript 라이브러리입니다.

물론 최근의 인터넷 환경에서는 이런 오래된 호환성 문제는 거의 발생하지 않기 때문에, 이 라이브러리를 적용하는게 큰 의미가 없지 않나 하는 고민이 있었어요. 이 부분은 타겟 유저를 고려해야 하는 부분이었지만, 일단 인터넷으로 가장 쉽게 찾을 수 있는 것이 SockJS 기반 구현이기도 해서, 혹시나 하는 마음으로 SockJS를 선택하게 되었습니다.

결정: 백엔드(Spring Boot)와 프론트엔드(JavaScript) 양쪽에서 SockJS를 사용하여 웹소켓 연결의 안정성과 호환성을 확보하기로 했습니다.

2.2. 메시징 프로토콜: STOMP의 필요성

순수 WebSocket은 낮은 레벨의 통신 프로토콜입니다. 즉, "어떤 메시지가 어떤 방으로 갈지", "구독은 어떻게 할지" 같은 고수준의 메시징 규칙이 없습니다. 이는 개발자가 직접 구현해야 합니다.

이러한 문제를 해결하기 위해 STOMP (Simple Text Oriented Messaging Protocol) 도입을 고려해봤습니다. STOMP는 웹소켓 위에서 동작하는 텍스트 기반 메시징 프로토콜로, 발행(Publish), 구독(Subscribe), 메시지 헤더 등 표준화된 메시지 프레임을 제공합니다.

STOMP를 사용하기로 결정한 주요 이유:

  • 메시지 라우팅: 특정 채널(예: chat:room:123)로 메시지를 쉽게 라우팅하고 구독할 수 있습니다.
  • 구독/발행 모델: Pub/Sub 모델을 웹소켓 위에서 자연스럽게 구현할 수 있습니다.
  • 개발 용이성: Spring WebSocketSTOMP.js 같은 라이브러리들이 STOMP 프로토콜을 잘 지원하여 개발 생산성을 높일 수 있습니다.
  • 결정: WebSocket 위에 STOMP 프로토콜을 사용하여 메시지 통신을 구조화하기로 했습니다.

> 3. 메시지 브로커 선택: Redis Pub/Sub의 매력

이제 클라이언트와 서버 간의 통신 채널(WebSocket + STOMP)은 결정되었습니다. 다음 고민은 서버 내부에서 메시지를 어떻게 효율적으로 전달하고, 여러 서버 인스턴스가 있을 때도 메시지가 모든 클라이언트에 도달하게 할 것인가였습니다. 즉, 메시지 브로커의 선택이 필요했습니다.

몇 가지 옵션을 고려했습니다.

인메모리 브로드캐스트 (단일 서버): 가장 간단한 방법은 서버가 메시지를 받으면, 해당 서버에 연결된 모든 클라이언트에게 직접 메시지를 보내는 방식입니다.

고민: 서버 한 대에서만 채팅방이 운영될 때는 문제가 없지만, 서버를 여러 대로 확장하는 순간 문제가 발생합니다. 특정 서버에 연결된 클라이언트는 다른 서버에서 발생한 메시지를 받을 수 없게 됩니다. 확장성 부족이라는 치명적인 단점이 있었습니다.

외부 메시지 브로커 (Kafka 등): 분산 환경에서 메시지를 안정적으로 전달하는 데 특화된 기술입니다.

고민: 강력하고 안정적이지만, 채팅의 경우 메시지의 즉각적인 전달이 중요하고, 복잡한 큐잉 로직보다는 단순한 Pub/Sub 모델이 더 적합하다고 판단했습니다. 또한, 별도의 브로커 서버를 구축하고 운영하는 복잡도도 고려해야 했습니다.

Redis Pub/Sub: Redis의 인메모리 특성을 활용한 Pub/Sub 기능.

장점:

  • 매우 빠르다: 인메모리 기반이라 메시지 전달 속도가 압도적으로 빠릅니다.

  • 확장성: 여러 개의 백엔드 서버가 각각 Redis를 구독하고 메시지를 발행하면, 모든 서버에 연결된 클라이언트에게 메시지를 브로드캐스팅할 수 있습니다. 특정 서버가 다운되어도 다른 서버를 통해 서비스가 지속될 수 있습니다.

  • 단순함: Kafka에 비해 직관적이고 구현이 간단합니다.

  • 경량성: 별도의 대규모 메시지 큐 시스템 구축보다 가볍게 시작할 수 있습니다.

단점:

  • 메시지 영속성 없음: Redis Pub/Sub은 메시지를 발행하면 구독 중인 클라이언트에게만 전달하고, 저장하지 않습니다. 구독자가 없으면 메시지는 사라집니다. 이는 채팅 기록을 보존해야 하는 요구사항과 충돌했습니다.

  • 메시지 전달 보장성 낮음: Redis Pub/Sub은 "Fire and Forget" 방식이므로, 구독자가 메시지를 받았는지는 확인하지 않습니다. 네트워크 문제 등으로 유실될 가능성이 있습니다.

Redis Pub/Sub메시지 영속성 없음전달 보장성 낮음이라는 단점은 분명했지만, 이는 "채팅 기록 저장""메시지 재전송"이라는 별도의 레이어를 추가하여 해결할 수 있다고 판단했습니다. 즉, Redis는 실시간성에 집중하고, 영속성은 RDBMS에 맡기는 방향으로 아키텍처를 설계하기로 했습니다.

결정: Redis Pub/Sub을 메시지 브로커로 사용하여 여러 백엔드 서버 간의 메시지 공유 및 실시간 브로드캐스팅을 담당하게 했습니다.


> 4. 아키텍처 최종 구상 및 데이터 흐름

1. 클라이언트 (프론트엔드):

  • SockJS + STOMP.js를 사용하여 백엔드 서버의 /ws/chat 엔드포인트로 WebSocket 연결을 수립합니다.

  • 채팅방에 입장하면 STOMP 구독 명령을 통해 /sub/chat/room/{roomId} 채널을 구독합니다.

  • 메시지 전송 시 STOMP 발행 명령을 통해 /pub/chat/message로 메시지를 발행합니다.

  • 채팅방에 접속할 때 REST API를 통해 백엔드 서버에서 이전 채팅 기록을 조회하여 화면에 표시합니다.

2. 백엔드 서버 (Spring Boot):

  • Spring WebSocket (@EnableWebSocketMessageBroker): 클라이언트의 WebSocket 연결을 처리하고, STOMP 메시지를 라우팅합니다.

  • /pub 접두사가 붙은 클라이언트 메시지는 @MessageMapping이 달린 컨트롤러(예: ChatController)로 전달합니다.

  • /sub 접두사가 붙은 클라이언트는 해당 채널을 구독하게 합니다.

3. Redis 클라이언트 (spring-boot-starter-data-redis):

  • RedisTemplate: 클라이언트로부터 받은 메시지를 JSON 형태로 직렬화하여 Redis Pub/Sub 채널에 발행 (publish)하는 역할을 합니다.

  • RedisMessageListenerContainer: Redis Pub/Sub 채널에 발행된 메시지를 구독(subscribe)하고 수신하는 역할을 합니다. 수신된 메시지는 @Service 계층의 RedisSubscriber로 전달됩니다.

4. 채팅 기록 저장/조회 (spring-boot-starter-data-jpa):

  • ChatService: 클라이언트로부터 받은 메시지를 Redis에 발행함과 동시에 관계형 데이터베이스(RDBMS)에도 저장합니다.

  • ChatRoomMessageRepository: 채팅방의 메시지를 영구적으로 저장하고, 클라이언트가 채팅방에 접속했을 때 이전 기록을 불러올 수 있도록 조회 기능을 제공합니다.

5. REST API (@RestController):

  • 채팅방 목록 조회, 새로운 채팅방 생성, 특정 채팅방의 이전 채팅 기록 조회 등 비실시간성 데이터를 위한 API를 제공합니다.

6. Redis 서버:

  • PUBLISH 명령을 통해 메시지를 받아 특정 CHANNEL에 발행합니다.

  • SUBSCRIBE 명령을 통해 특정 CHANNEL을 구독하고 있는 모든 클라이언트(RedisMessageListenerContainer 인스턴스들)에게 메시지를 즉시 전달합니다. Redis는 메시지를 저장하지 않고 전달만 합니다.

7. 데이터베이스 (RDBMS - 예: MySQL):

  • 채팅 메시지, 채팅방 정보 등 영구적으로 보존해야 하는 데이터를 저장합니다. Redis Pub/Sub의 휘발성을 보완하는 역할을 합니다.

> 5. 백엔드 구현: Spring Boot 기반 (1/2) - 기본 설정 및 WebSocket/STOMP

이제 실제 코드를 통해 위의 아키텍처를 구현해 나가겠습니다. 먼저 Spring Boot 프로젝트의 기본 설정과 WebSocket, STOMP 관련 부분을 다루겠습니다.

5.1. 프로젝트 의존성 추가 (build.gradle)

프로젝트에 필요한 라이브러리들을 build.gradle에 추가합니다. Lombok은 선택 사항이지만, 코드 생산성을 높이는 데 큰 도움이 됩니다.

Gradle

// build.gradle (Spring Boot 프로젝트)
dependencies {
    // Spring Web: REST API 및 일반 웹 기능
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // Spring WebSocket: WebSocket 및 STOMP 통신을 위한 핵심 라이브러리
    implementation 'org.springframework.boot:spring-boot-starter-websocket'

    // Spring Data Redis: Redis 연동 (Pub/Sub 및 데이터 저장/조회)
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'

    // Spring Data JPA: 관계형 데이터베이스 연동 (채팅 기록 영구 저장)
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // H2 Database: 개발/테스트용 인메모리 데이터베이스 (실제 서비스에서는 PostgreSQL, MySQL 등 사용)
    runtimeOnly 'com.h2database:h2'

    // Lombok: Getter, Setter, Constructor 자동 생성 등 보일러플레이트 코드 감소
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // 테스트 관련 (기본 포함)
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

처음에는 spring-boot-starter-websocket만 추가하면 될 줄 알았습니다. 하지만 SockJS를 사용하여 클라이언트 호환성을 높이려면, 서버 측에서도 withSockJS() 설정을 해주어야 한다는 것을 알게 되었습니다. 또한, 메시지 라우팅의 복잡도를 줄이기 위해 STOMP 프로토콜을 사용하기로 결정하면서, Spring Boot 안에 내장된 @WebSocketMessageBrokerConfigurer를 활용하게 되었습니다.

이 부분은 아래의 블로그 글을 참조했어요:
https://kimbob.pages.dev/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC/%EC%9B%B9%EC%86%8C%EC%BC%93/WebSocketMessageBrokerConfigurer

5.2. WebSocket 및 STOMP 설정 (WebSocketConfig.java)

클라이언트가 웹소켓 연결을 요청할 엔드포인트와, STOMP 메시지 라우팅 규칙을 정의합니다.

Java

package com.example.chat.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker // STOMP 기반 웹소켓 메시지 브로커를 활성화합니다.
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * 메시지 브로커를 구성합니다.
     * 클라이언트가 보낸 메시지를 라우팅할 경로와, 서버가 클라이언트에게 보낼 메시지를 라우팅할 경로를 정의합니다.
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 1. 클라이언트 -> 서버
        // "/pub"으로 시작하는 STOMP 메시지는 @MessageMapping 어노테이션이 붙은 컨트롤러 메서드로 라우팅됩니다.
        // 예를 들어, 클라이언트가 "/pub/chat/message"로 메시지를 보내면 ChatController에서 이를 받습니다.
        config.setApplicationDestinationPrefixes("/pub");

        // 2. 서버 -> 클라이언트
        // "/sub"으로 시작하는 메시지는 메시지 브로커를 통해 클라이언트에게 전달됩니다.
        // 클라이언트는 "/sub/chat/room/{roomId}"와 같은 경로를 구독하여 메시지를 수신합니다.
        // 이 SimpleBroker는 기본적으로 인메모리 브로커이지만, 실제 메시지는 Redis Pub/Sub을 통해
        // 모든 서버 인스턴스에 전달된 후, 각 서버 인스턴스에 연결된 클라이언트에게 뿌려집니다.
        // 이 부분은 RedisConfig와 RedisSubscriber에서 실제 Redis 연동 로직으로 보완됩니다.
        config.enableSimpleBroker("/sub");
    }

    /**
     * STOMP 웹소켓 연결을 위한 엔드포인트를 등록합니다.
     * 클라이언트가 이 경로로 웹소켓 연결을 시도합니다.
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // "/ws/chat" 엔드포인트를 통해 웹소켓 연결을 허용합니다.
        // .withSockJS()를 추가하여 WebSocket을 지원하지 않는 환경에서 SockJS 폴백을 활성화합니다.
        // 이로써 다양한 브라우저 및 네트워크 환경에서 안정적인 연결을 보장할 수 있습니다.
        registry.addEndpoint("/ws/chat").withSockJS();
    }
}

configureMessageBroker에서 enableSimpleBroker를 설정할 때, 'Redis를 쓰는데 왜 SimpleBroker를 사용하지?'라는 의문이 들었습니다. 찾아본 결과 다음과 같았습니다.

  • enableStompBrokerRelaySpring Boot 애플리케이션 외부에 있는 별도의 STOMP 메시지 브로커와 연동할 때 사용합니다. 예를 들어, RabbitMQKafka 같은 전문적인 메시지 큐 시스템들이 STOMP 프로토콜을 지원할 때 이 설정을 씁니다.

  • Redis Pub/Sub은 위에서 언급한 RabbitMQKafka와는 작동 방식이 조금 다릅니다. Redis는 자체적으로 STOMP 프로토콜을 지원하지 않습니다. 대신 Spring Data RedisRedisMessageListenerContainer를 통해 Redis Pub/Sub 기능을 직접 연동해서 사용합니다.
    여기서 enableSimpleBroker의 역할이 중요해집니다. enableSimpleBrokerSpring 내부에서 간단한 인메모리(in-memory) 브로커를 활성화하는 설정이에요. 이 '간단한 브로커'는 클라이언트가 "/sub"과 같은 가상 목적지(Destination)로 메시지를 구독할 수 있도록 해주는 역할을 합니다.


6. 백엔드 구현: Spring Boot 기반 (2/2) - Redis Pub/Sub 연동 및 메시지 영속성

6.1. Redis Pub/Sub 연동 및 메시지 영속성

이제 채팅 서비스 아키텍처의 핵심이라고 할 수 있는 Redis Pub/Sub 연동과 메시지 영속성 처리에 대해 살펴보겠습니다.

RestTemplate 설정: 외부 HTTP 통신 준비
먼저, Redis Pub/Sub과는 직접적인 관련이 없지만, 외부 REST API 통신이 필요할 경우를 대비하여 RestTemplate을 스프링 빈으로 등록하는 설정입니다.

Java

package moneybuddy.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {
    @Bean
    public RestTemplate restTemplate() {
        // RestTemplate은 HTTP 통신을 간편하게 수행할 수 있도록 도와주는 스프링의 핵심 클래스입니다.
        // 웹 서비스를 호출하거나 RESTful API와 상호작용할 때 사용됩니다.
        // 여기서는 기본 생성자를 사용하여 가장 기본적인 형태의 RestTemplate 빈을 등록합니다.
        // 필요에 따라 MessageConverter 등을 추가 설정할 수 있습니다. 구체적인 구현은 아래에서 했습니다.
        return new RestTemplate();
    }
}

Redis 메시지 발행(Publish) 서비스: RedisPublisher

채팅 메시지가 발생하면, 이 메시지를 Redis Pub/Sub 채널로 발행(Publish)하여 다른 모든 서버 인스턴스와 공유해야 합니다. 이 역할을 담당하는 것이 바로 RedisPublisher 서비스입니다.

Java

package moneybuddy.util;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import moneybuddy.domain.consultation.dto.ConsultationMessageDto;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.stereotype.Service;

/**
 * ## Redis 메시지 발행(Publish) 서비스: `RedisPublisher`
 *
 * 이 서비스는 채팅 메시지를 Redis의 특정 채널(Topic)으로 발행하는 역할을 합니다.
 * 여러 백엔드 서버 인스턴스 간에 실시간 메시지를 공유하기 위한 핵심 컴포넌트입니다.
 *
 * `@RequiredArgsConstructor`: `final` 필드에 대한 생성자를 자동으로 생성하여 의존성을 주입합니다.
 * `@Service`: Spring의 서비스 계층 컴포넌트임을 나타냅니다.
 * `@Slf4j`: Lombok을 사용하여 로그를 쉽게 사용할 수 있도록 합니다.
 */
@Slf4j
@RequiredArgsConstructor
@Service
public class RedisPublisher {

    /**
     * `StringRedisTemplate`: Redis에 문자열 데이터를 저장하고 조회할 때 사용하는 템플릿입니다.
     * 여기서는 메시지 객체를 JSON 문자열 형태로 변환하여 Redis에 발행하는 데 사용됩니다.
     * `RedisTemplate<String, Object>`를 사용하여 Value Serializer를 Jackson2JsonRedisSerializer로
     * 명시적으로 설정하는 방식과 유사한 효과를 내면서, Key와 Value 모두 `String`으로 다루기 위함입니다.
     */
    private final StringRedisTemplate redisTemplate;

    /**
     * `ObjectMapper`: Jackson 라이브러리의 핵심 클래스로, Java 객체를 JSON 문자열로,
     * JSON 문자열을 Java 객체로 변환(직렬화/역직렬화)하는 데 사용됩니다.
     * `JavaTimeModule`을 등록하여 `LocalDateTime` 등 Java 8 날짜/시간 API를 JSON으로 올바르게 처리합니다.
     */
    private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());

    /**
     * ### 메시지 발행 메서드
     *
     * 전달받은 `ConsultationMessageDto` 객체를 JSON 문자열로 변환하여
     * 지정된 Redis Topic으로 발행합니다. 이 메시지는 해당 토픽을 구독하는
     * 모든 Redis 클라이언트(다른 백엔드 서버 인스턴스)에게 실시간으로 전달됩니다.
     *
     * @param topic 발행할 Redis 채널 토픽 (예: "consultation-room:123")
     * @param consultationMessageDto 발행할 채팅 메시지 데이터 객체
     */
    public void publish(ChannelTopic topic, ConsultationMessageDto consultationMessageDto) {
        try {
            // 1. ConsultationMessageDto 객체를 JSON 문자열로 직렬화합니다.
            // 이 과정에서 ObjectMapper는 객체의 필드들을 JSON 키-값 쌍으로 매핑합니다.
            String json = objectMapper.writeValueAsString(consultationMessageDto);

            // 2. StringRedisTemplate의 `convertAndSend` 메서드를 사용하여
            // 지정된 Redis 토픽(`topic.getTopic()`)으로 직렬화된 JSON 메시지를 발행합니다.
            redisTemplate.convertAndSend(topic.getTopic(), json);

            log.info("RedisPublisher - Published message to topic {}: {}", topic.getTopic(), json);
        } catch (Exception e) {
            log.error("RedisPublisher - Error publishing message: {}", e.getMessage());
            // 메시지 발행 실패 시 예외 처리 및 로깅을 통해 문제 진단을 돕습니다.
            // 실제 운영 환경에서는 재시도 로직이나 데드 레터 큐(DLQ)로의 전송 등 추가적인 전략을 고려할 수 있습니다.
        }
    }
}

Redis 메시지 구독(Subscribe) 및 처리 서비스: RedisSubscriber

Redis Pub/Sub을 통해 메시지가 발행되면, 이를 수신하고 다시 웹소켓으로 클라이언트에게 전달하는 역할을 RedisSubscriber가 수행합니다. 이 서비스는 MessageListener 인터페이스를 구현하여 Redis 메시지 리스너 컨테이너에 등록됩니다.

Java

package moneybuddy.util;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import moneybuddy.domain.consultation.dto.ConsultationMessageDto;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

/**
 * ## Redis 메시지 구독(Subscribe) 및 처리 서비스: `RedisSubscriber`
 *
 * 이 서비스는 Redis로부터 발행된 메시지를 수신하여 처리하는 역할을 합니다.
 * `MessageListener` 인터페이스를 구현하여 `RedisMessageListenerContainer`에 등록됩니다.
 * 수신된 메시지를 역직렬화하여 WebSocket(STOMP)을 통해 클라이언트에게 실시간으로 전달합니다.
 *
 * `@RequiredArgsConstructor`: `final` 필드에 대한 생성자를 자동으로 생성하여 의존성을 주입합니다.
 * `@Service`: Spring의 서비스 계층 컴포넌트임을 나타냅니다.
 * `@Slf4j`: Lombok을 사용하여 로그를 쉽게 사용할 수 있도록 합니다.
 */
@Slf4j
@RequiredArgsConstructor
@Service
public class RedisSubscriber implements MessageListener {

    /**
     * `SimpMessagingTemplate`: Spring WebSocket(STOMP)을 통해 클라이언트에게 메시지를 전송하는 데 사용됩니다.
     * 서버에서 특정 STOMP 목적지(destination)로 메시지를 브로드캐스트할 때 핵심적으로 활용됩니다.
     */
    private final SimpMessagingTemplate messagingTemplate;

    /**
     * `ObjectMapper`: Jackson 라이브러리의 핵심 클래스로, JSON 문자열을 Java 객체로 역직렬화하는 데 사용됩니다.
     * 이 `ObjectMapper`는 스프링 빈으로 주입받아 사용되므로, 애플리케이션 전반에 걸친 JSON 처리 설정이 일관되게 적용됩니다.
     */
    private final ObjectMapper objectMapper;

    /**
     * ### Redis 메시지 수신 및 처리 메서드
     *
     * `MessageListener` 인터페이스의 추상 메서드인 `onMessage`를 구현합니다.
     * Redis 채널에 메시지가 발행될 때마다 `RedisMessageListenerContainer`에 의해 호출됩니다.
     * 이 메서드 내에서 수신된 메시지를 역직렬화하고 웹소켓으로 중계하는 로직이 수행됩니다.
     *
     * @param message Redis로부터 수신된 메시지 객체 (본문은 바이트 배열 형태)
     * @param pattern 구독 패턴 (패턴 구독 시에만 유효하며, 여기서는 주로 사용되지 않습니다.)
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            // 1. 수신된 메시지 본문이 유효한지(null이 아니고 비어있지 않은지) 먼저 확인합니다.
            // 불필요한 처리나 에러 발생을 방지합니다.
            if (message.getBody() == null || message.getBody().length == 0) {
                log.warn("RedisSubscriber - 빈 메시지 수신");
                return;
            }

            // 2. Redis로부터 받은 메시지 본문(바이트 배열)을 UTF-8 인코딩을 사용하여 JSON 문자열로 변환합니다.
            // RedisPublisher에서 StringRedisTemplate을 통해 JSON 문자열로 발행했으므로, 여기서 다시 문자열로 디코딩합니다.
            String json = new String(message.getBody());

            // 3. JSON 문자열을 `ConsultationMessageDto` 객체로 역직렬화합니다.
            // ObjectMapper가 JSON 데이터의 구조를 ConsultationMessageDto 클래스에 맞게 자동으로 매핑합니다.
            ConsultationMessageDto consultationMessage = objectMapper.readValue(json, ConsultationMessageDto.class);

            // 4. 역직렬화된 메시지 객체를 STOMP WebSocket을 통해 클라이언트에게 전달합니다.
            // `/sub/chat/room/{roomId}` 목적지로 메시지를 보내면,
            // 해당 채팅방을 구독하고 있는 모든 웹소켓 클라이언트에게 실시간으로 메시지가 푸시됩니다.
            messagingTemplate.convertAndSend("/sub/chat/room/" + consultationMessage.consultationRoomId(), consultationMessage);

            log.info("RedisSubscriber - roomId: {}, message: {}", consultationMessage.consultationRoomId(), consultationMessage.message());

        } catch (Exception e) {
            log.error("RedisSubscriber - 메시지 처리 중 에러", e);
            // 메시지 역직렬화 실패 또는 웹소켓 전송 실패 시 적절한 로깅을 수행합니다.
        }
    }
}

Redis 채널 동적 구독 관리 서비스: RedisSubscriberService

저는 모든 채팅방 채널을 미리 등록하는 대신, 필요할 때마다 동적으로 구독하는 방식을 채택했습니다. 이는 채팅방이 계속 생성될 수 있는 환경에서 유연하고 확장 가능한 아키텍처를 구축하기 위함입니다. RedisSubscriberService가 이 동적 구독을 관리합니다.

Java

package moneybuddy.util;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.stereotype.Service;

/**
 * ## Redis 채널 구독 관리 서비스: `RedisSubscriberService`
 *
 * 이 서비스는 `RedisMessageListenerContainer`를 통해 Redis 채널 구독을 동적으로 관리합니다.
 * 특히, 새로운 채팅방이 생성될 때마다 해당 방의 메시지를 구독할 수 있도록 토픽을 등록하는 역할을 합니다.
 * 이는 채팅방의 수가 가변적인 환경에서 유연하게 대응하기 위한 핵심 전략입니다.
 *
 * `@RequiredArgsConstructor`: `final` 필드에 대한 생성자를 자동으로 생성하여 의존성을 주입합니다.
 * `@Service`: Spring의 서비스 계층 컴포넌트임을 나타냅니다.
 */
@Service
@RequiredArgsConstructor
public class RedisSubscriberService {

    /**
     * `RedisMessageListenerContainer`: Redis Pub/Sub 메시지를 듣는(구독하는) 컨테이너입니다.
     * 이 컨테이너는 등록된 리스너(`MessageListenerAdapter`)와 채널 토픽을 관리하며,
     * 해당 채널에 메시지가 발행되면 리스너에게 전달하는 역할을 수행합니다.
     */
    private final RedisMessageListenerContainer container;

    /**
     * `MessageListenerAdapter`: `RedisSubscriber`와 같은 사용자 정의 리스너 클래스의 특정 메서드(기본값: `onMessage`)를
     * `RedisMessageListenerContainer`에 등록할 수 있도록 감싸는하는 어댑터입니다.
     * 이를 통해 `RedisSubscriber`의 `onMessage` 메서드가 Redis 메시지를 수신할 때 호출되도록 연결해 줍니다.
     */
    private final MessageListenerAdapter listenerAdapter;

    /**
     * ### 채팅방(roomId)별 Redis 채널 동적 구독 메서드
     *
     * 새로운 채팅방이 생성되거나, 애플리케이션 시작 시 기존 채팅방들을 구독해야 할 때 호출됩니다.
     * 이 메서드를 통해 특정 채팅방 ID에 해당하는 Redis 채널을 `RedisMessageListenerContainer`에 등록하여
     * 해당 채널로 발행되는 메시지를 `RedisSubscriber`가 수신하고 처리할 수 있도록 합니다.
     *
     * @param consultationRoomId 구독할 채팅방의 고유 ID
     */
    public void subscribeChatRoom(Long consultationRoomId) {
        // 1. 구독할 Redis 채널의 이름을 정의합니다.
        // 일관된 규칙(예: "consultation-room:{ID}")을 사용하여 채널 이름을 구성합니다.
        String topicName = "consultation-room:" + consultationRoomId;
        ChannelTopic topic = new ChannelTopic(topicName);

        // 2. `RedisMessageListenerContainer`에 `MessageListenerAdapter`와 함께
        // 정의된 `ChannelTopic`을 추가하여 해당 채널의 메시지를 구독하도록 등록합니다.
        // 이렇게 등록되면, 해당 토픽으로 발행되는 모든 메시지는 `listenerAdapter`를 통해 `RedisSubscriber`의 `onMessage` 메서드로 전달됩니다.
        container.addMessageListener(listenerAdapter, topic);

        // TODO: 현재는 단순히 구독을 등록하는 방식입니다.
        // 서비스 요구사항에 따라 이미 구독 중인 채널에 대한 중복 구독 방지 로직이나,
        // 필요 없는 채널을 구독 해제하는 로직 등을 추가하여 개선할 수 있습니다.
    }
}

Redis 설정: RedisConfig 들여다보기

현재 서비스의 핵심인 Redis와의 연결과 데이터 처리를 담당하는 RedisConfig 클래스입니다. 이 설정 파일은 Spring Boot의 자동 설정이 아닌, 저희가 직접 Redis 연결 방식, 데이터 직렬화/역직렬화, 그리고 Pub/Sub 메시지 리스너 컨테이너를 어떻게 구성할지 정의하는 곳입니다. 이를 통해 Redis를 활용한 실시간 채팅 시스템의 기반을 다집니다.

Java

package moneybuddy.global.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.RequiredArgsConstructor;
import moneybuddy.util.RedisSubscriber;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic; // 단일 채널 토픽 (여기서는 사용되지 않음)
import org.springframework.data.redis.listener.PatternTopic; // 패턴 기반 채널 토픽
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; // JSON 직렬화
import org.springframework.data.redis.serializer.StringRedisSerializer; // 문자열 직렬화

/**
 * ## Redis 설정 클래스: `RedisConfig`
 *
 * 이 클래스는 Spring Data Redis를 사용하여 Redis 서버와 연결하고, 데이터를 주고받는 방식,
 * 그리고 Redis Pub/Sub 메시지를 처리하는 방법을 정의하는 핵심 설정 파일입니다.
 *
 * `@Configuration`: 이 클래스가 스프링의 설정(Configuration) 클래스임을 나타내며,
 * 내부에 정의된 `@Bean` 메서드들을 통해 스프링 컨테이너에 빈(Bean)을 등록합니다.
 * `@RequiredArgsConstructor`: `final` 필드들을 초기화하는 생성자를 자동으로 생성합니다.
 */
@Configuration
@RequiredArgsConstructor
public class RedisConfig {

    // application.properties 또는 application.yml 파일에 정의된 Redis 설정 정보를 주입받습니다.
    // (예: spring.data.redis.host, spring.data.redis.port)
    private final RedisProperties redisProperties;

    // Redis Pub/Sub 메시지를 실제로 처리할 RedisSubscriber 빈을 주입받습니다.
    private final RedisSubscriber redisSubscriber;

    /**
     * ### Redis 연결 팩토리 설정: `redisConnectionFactory`
     *
     * Spring Data Redis가 Redis 서버와 연결하기 위한 팩토리(Factory)를 정의합니다.
     *
     * @return RedisConnectionFactory (LettuceConnectionFactory 구현체)
     */
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        // RedisProperties에서 읽어온 호스트(host)와 포트(port) 정보를 사용하여
        // LettuceConnectionFactory 인스턴스를 생성하고 반환합니다.
        // 이를 통해 Spring Data Redis가 Redis 서버와 물리적인 연결을 맺을 수 있습니다.
        return new LettuceConnectionFactory(
                redisProperties.getHost(),
                redisProperties.getPort()
        );
    }

    /**
     * ### Redis 데이터 템플릿 설정: `redisTemplate`
     *
     * `RedisTemplate`은 Redis 데이터를 조작하기 위한 핵심 템플릿입니다.
     * Java 객체를 Redis에 저장하거나 Redis로부터 가져올 때 사용되는 직렬화(Serialization) 및
     * 역직렬화(Deserialization) 방식을 정의하는 것이 중요합니다.
     * 저희는 채팅 메시지처럼 복합적인 데이터를 다루기 위해 JSON 직렬화를 선택했습니다.
     *
     * @return RedisTemplate<String, Object> (Key는 String, Value는 모든 Object)
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        // 위에서 정의한 RedisConnectionFactory를 설정하여, RedisTemplate이 Redis 서버와 통신할 수 있게 합니다.
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        // 1. ObjectMapper 설정: Java 객체를 JSON으로 변환하기 위한 Jackson ObjectMapper를 생성합니다.
        // `JavaTimeModule`을 등록하여 LocalDateTime 등 날짜/시간 API 객체도 올바르게 JSON으로 변환될 수 있도록 합니다.
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());

        // 2. Key 직렬화: Redis의 Key는 주로 문자열로 사용되므로, `StringRedisSerializer`를 사용합니다.
        // 이는 Key가 Redis에 사람이 읽기 쉬운 문자열 형태로 저장되도록 합니다.
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        
        // 3. Value 직렬화: Java 객체(ChatMessage 등)를 JSON 형태로 Redis에 저장하기 위해
        // `GenericJackson2JsonRedisSerializer`를 사용합니다. `Generic` 버전은 모든 `Object` 타입을
        // JSON으로 처리할 수 있으며, 역직렬화 시 타입 정보를 포함하여 유연성을 높여줍니다.
        // 이 설정 덕분에 Redis에 저장된 데이터는 개발자가 쉽게 확인하고 이해할 수 있는 JSON 형태가 됩니다.
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));
        
        // 4. Hash Key/Value 직렬화: Redis Hash 타입 데이터를 사용할 경우를 대비하여
        // Hash Key는 String으로, Hash Value는 JSON 형태로 직렬화되도록 설정합니다.
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));

        return redisTemplate;
    }

    /**
     * ### Redis 채널 토픽 정의 (단일): `channelTopic`
     *
     * 단일 채널을 구독할 때 사용하는 `ChannelTopic` 빈을 정의합니다.
     * 이 특정 빈은 현재 `redisMessageListenerContainer`에서 직접 사용되지는 않고,
     * `RedisPublisher` 등에서 특정 고정된 채널(`"consultation-room"`)로 메시지를 발행할 때 사용될 수 있습니다.
     * (현재 Pub/Sub 리스너는 패턴 토픽을 사용합니다.)
     *
     * @return ChannelTopic ("consultation-room"이라는 이름의 토픽)
     */
    @Bean
    public ChannelTopic channelTopic() {
        // "consultation-room"이라는 고정된 이름을 가진 채널 토픽을 생성합니다.
        return new ChannelTopic("consultation-room");
    }

    /**
     * ### Redis 메시지 리스너 컨테이너: `redisMessageListenerContainer`
     *
     * Redis Pub/Sub 메시지를 수신하는 핵심 컨테이너입니다.
     * 이 컨테이너는 Redis 서버로부터 발행되는 메시지를 듣고, 등록된 리스너(`MessageListenerAdapter`)에게 전달하는 역할을 합니다.
     * 채팅방처럼 동적으로 생성되는 채널들을 효율적으로 구독하기 위해 '패턴 토픽' 방식을 사용합니다.
     *
     * @return RedisMessageListenerContainer (메시지 수신 및 전달 관리)
     */
    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer() {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        // 위에서 정의한 RedisConnectionFactory를 설정하여 Redis 서버와의 연결을 확립합니다.
        container.setConnectionFactory(redisConnectionFactory());

        // ### 중요: 패턴 토픽으로 리스너 등록!
        // `container.addMessageListener()`를 사용하여 `MessageListenerAdapter`와 함께
        // 'consultationRoom:*'이라는 `PatternTopic`을 등록합니다.
        // 이는 "consultationRoom:"으로 시작하는 모든 채널(예: consultationRoom:123, consultationRoom:abc)의
        // 메시지를 `listenerAdapter` (즉, `RedisSubscriber`의 `onMessage` 메서드)가 수신하도록 합니다.
        // 이 방식은 새로운 채팅방이 생성될 때마다 개별 채널을 명시적으로 구독하지 않아도 되어,
        // 채팅방 개수가 가변적인 환경에서 아키텍처를 훨씬 유연하고 확장 가능하게 만듭니다.
        container.addMessageListener(listenerAdapter(), new PatternTopic("consultationRoom:*"));
        return container;
    }

    /**
     * ### 메시지 리스너 어댑터: `listenerAdapter`
     *
     * `RedisSubscriber`와 같은 사용자 정의 리스너 클래스의 특정 메서드를
     * `RedisMessageListenerContainer`에 등록할 수 있도록 래핑(wrapping)하는 어댑터입니다.
     * Redis 메시지를 수신했을 때 `redisSubscriber`의 `onMessage` 메서드가 호출되도록 연결해 줍니다.
     *
     * @return MessageListenerAdapter (`RedisSubscriber`를 래핑)
     */
    @Bean
    public MessageListenerAdapter listenerAdapter() {
        // `redisSubscriber` 빈 인스턴스와, 메시지 수신 시 호출될 메서드 이름("onMessage")을 지정하여
        // MessageListenerAdapter를 생성합니다.
        return new MessageListenerAdapter(redisSubscriber, "onMessage");
    }
}

6.3. 채팅 메시지 모델 (ChatMessage.java)

클라이언트와 서버 간에 주고받을 메시지의 형식을 정의하는 DTO(Data Transfer Object)이자, JPA 엔티티로도 활용될 모델입니다.

package moneybuddy.domain.consultation.entity;

import jakarta.persistence.*;
import lombok.*;
import moneybuddy.domain.user.entity.User;
import moneybuddy.global.enums.MessageType;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;

@Entity
@Table(name = "consultation_messages")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ConsultationMessage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "consultation_room_id", nullable = false)
    private ConsultationRoom consultationRoom;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "sender_id", nullable = false)
    private User sender;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "receiver_id", nullable = false)
    private User receiver;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String message;

    @Column(columnDefinition = "TEXT")
    private String imageUrl;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private MessageType type;

    private boolean isDeletedBySender;
    private boolean isDeletedByReceiver;

    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @UpdateTimestamp
    @Column(nullable = false)
    private LocalDateTime updatedAt;
}

package moneybuddy.global.enums;

import io.swagger.v3.oas.annotations.media.Schema;

/**
 * 채팅 메시지의 타입을 정의하는 열거형(Enum)입니다.
 * TEXT: 일반 텍스트 메시지
 * IMAGE: 이미지 메시지
 * SYSTEM: 시스템 알림 메시지 (예: 입장/퇴장 알림 등)
 */
@Schema(description = "채팅 메시지 타입")
public enum MessageType {

    @Schema(description = "일반 텍스트 메시지")
    TEXT,

    @Schema(description = "이미지 메시지")
    IMAGE,

    @Schema(description = "시스템 알림 메시지")
    SYSTEM
}

MessageType Enum을 추가한 것은 단순히 텍스트 메시지만 주고받는 것을 넘어, 시스템 메시지(입장/퇴장)를 구분하여 처리하기 위함이었습니다. 이를 통해 클라이언트에서 "누구누구님이 입장했습니다"와 같은 메시지를 특별하게 표시할 수 있게 됩니다. timestamp(createdAt, updatedAt)는 어느 엔티티에나 마찬가지지만, 메시지 순서를 보장하고 시간 정보를 표시하는 데 필수적이었습니다.

6.4. 채팅방 컨트롤러 (ChatController.java)

ConsultationMessageController는 클라이언트와 서버 간의 실시간 메시지 통신을 담당하는 핵심 컴포넌트입니다. 이 컨트롤러는 크게 두 가지 기능을 수행합니다. 첫째, WebSocket을 통해 클라이언트로부터 실시간 채팅 메시지를 수신하고 처리합니다. 둘째, 채팅방 내에서 이미지를 업로드하는 REST API를 제공합니다.

package moneybuddy.domain.consultation.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import moneybuddy.domain.consultation.dto.ConsultationImageUploadResponseDto;
import moneybuddy.domain.consultation.dto.ConsultationMessageDto;
import moneybuddy.domain.consultation.service.ConsultationMessageService;
import moneybuddy.domain.user.entity.User;
import moneybuddy.util.RedisPublisher;
import moneybuddy.util.S3Uploader;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

/**
 * 상담 채팅 메시지 및 이미지 업로드 컨트롤러
 * 실시간 WebSocket 채팅 및 이미지 업로드 API를 제공합니다.
 * `@RequiredArgsConstructor`: 'final' 필드에 대한 생성자를 자동으로 생성하여 의존성 주입을 처리합니다.
 * `@RestController`: 이 클래스가 RESTful 웹 서비스의 컨트롤러임을 나타내며, 모든 메서드의 반환 값이 HTTP 응답 본문으로 직접 전송됩니다.
 * `@RequestMapping("/api/v1/consultation")`: 이 컨트롤러 내의 모든 핸들러 메서드는 기본적으로 '/api/v1/consultation' 경로로 매핑됩니다.
 * `@Slf4j`: Lombok을 사용하여 로깅 기능을 쉽게 사용할 수 있도록 합니다.
 */
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/consultation")
public class ConsultationMessageController {

    // ConsultationMessageService: 채팅 메시지의 비즈니스 로직(저장 등)을 처리합니다.
    private final ConsultationMessageService consultationMessageService;
    // S3Uploader: 이미지 파일을 Amazon S3에 업로드하는 유틸리티입니다.
    private final S3Uploader s3Uploader;
    // RedisPublisher: 메시지를 Redis Pub/Sub 채널로 발행하는 유틸리티입니다.
    private final RedisPublisher redisPublisher;

    /**
     * ### WebSocket 메시지 수신 처리: `publishMessage`
     *
     * 클라이언트가 `/pub/consultation/chat` (기본 접두사 `/pub` 포함) 주소로 WebSocket 메시지를 보낼 때 이 메서드가 호출됩니다.
     * 수신된 메시지는 Redis Pub/Sub 채널로 발행되어 다른 서버 인스턴스에 전달되며, 동시에 데이터베이스에도 저장됩니다.
     *
     * `@MessageMapping("/chat")`: STOMP WebSocket 메시지가 이 경로로 매핑됩니다.
     *
     * @param messageDto 클라이언트로부터 수신한 메시지 데이터를 담고 있는 DTO (Data Transfer Object)
     * @param message WebSocket Message 객체. 여기서는 세션 속성에서 사용자 정보를 추출하는 데 사용됩니다.
     */
    @MessageMapping("/chat")
    public void publishMessage(ConsultationMessageDto messageDto, Message<?> message) {
        // SimpMessageHeaderAccessor를 사용하여 WebSocket 메시지 헤더에 접근합니다.
        SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.wrap(message);
        // WebSocket 세션에 저장된 로그인 사용자 정보를 추출합니다.
        User loginUser = (User) accessor.getSessionAttributes().get("user");

        // 사용자 인증 확인: 세션에 로그인한 사용자 정보가 없으면 예외를 발생시킵니다.
        if (loginUser == null) {
            log.error("WebSocket 인증 실패: 로그인 사용자 없음. 세션 ID: {}", accessor.getSessionId());
            throw new IllegalArgumentException("WebSocket 인증 실패: 로그인 사용자 없음");
        }

        log.info("[Message Received] consultationRoomId: {}, content: {}", messageDto.consultationRoomId(), messageDto.message());

        // 메시지 DTO에 로그인한 사용자 ID를 강제로 주입합니다.
        // 이는 클라이언트가 보낸 데이터의 무결성을 보장하고, 서버 측에서 정확한 사용자 정보를 기반으로 메시지를 처리하기 위함입니다.
        ConsultationMessageDto updatedMessage = new ConsultationMessageDto(
                messageDto.consultationRoomId(),
                loginUser.getId(), // 서버에서 확인된 로그인 사용자 ID 주입
                messageDto.senderNickname(),
                messageDto.message(),
                messageDto.type(),
                messageDto.imageUrl(),
                messageDto.sentAt()
        );

        // 1. 메시지를 데이터베이스에 저장합니다. (영속성 처리)
        consultationMessageService.saveMessage(updatedMessage);

        // 2. 메시지를 Redis Pub/Sub 채널로 발행합니다.
        // `consultationRoom:{roomId}` 형태의 토픽을 생성하여 해당 방의 메시지를 발행합니다.
        ChannelTopic topic = new ChannelTopic("consultationRoom:" + updatedMessage.consultationRoomId());
        redisPublisher.publish(topic, updatedMessage);

        log.info("🔹 Redis Publish to topic: {} with message: {}",
                topic.getTopic(), updatedMessage.message());
    }

    /**
     * ### 상담 이미지 업로드 API: `uploadConsultationImage`
     *
     * 채팅 메시지에 첨부할 이미지를 Amazon S3에 업로드하고, 업로드된 이미지의 URL을 클라이언트에게 반환하는 REST API입니다.
     * 이 URL을 클라이언트가 메시지 내용의 일부로 사용하여 이미지를 표시할 수 있습니다.
     *
     * `@Operation`, `@ApiResponses`, `@Parameter`: Swagger/OpenAPI 문서화를 위한 어노테이션입니다.
     * API의 목적, 응답 형식, 파라미터 등을 명확하게 설명합니다.
     * `@PostMapping("/{consultationRoomId}/image")`: HTTP POST 요청을 '/api/v1/consultation/{consultationRoomId}/image' 경로로 매핑합니다.
     * `@PathVariable("consultationRoomId")`: URL 경로에서 상담방 ID를 추출합니다.
     * `@RequestPart("file")`: multipart/form-data 요청에서 'file'이라는 이름의 파일 부분을 추출하여 `MultipartFile` 객체로 바인딩합니다.
     *
     * @param consultationRoomId 이미지 업로드 대상 상담방 ID
     * @param file 업로드할 이미지 파일
     * @return 업로드된 이미지 URL을 포함한 DTO (HTTP 200 OK 상태 코드와 함께 반환)
     */
    @Operation(summary = "상담 이미지 업로드", description = "상담방 내에서 사용할 이미지를 업로드하고 URL을 반환합니다.")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "업로드 성공",
            content = @Content(schema = @Schema(implementation = ConsultationImageUploadResponseDto.class))),
        @ApiResponse(responseCode = "400", description = "잘못된 요청 (예: 파일 없음)", content = @Content),
        @ApiResponse(responseCode = "500", description = "서버 오류 (예: S3 업로드 실패)", content = @Content)
    })
    @PostMapping("/{consultationRoomId}/image")
    public ResponseEntity<ConsultationImageUploadResponseDto> uploadConsultationImage(
            @Parameter(description = "이미지를 업로드할 상담방 ID", example = "1")
            @PathVariable("consultationRoomId") Long consultationRoomId,

            @Parameter(description = "업로드할 이미지 파일", required = true)
            @RequestPart("file") MultipartFile file
    ) {
        // S3Uploader 서비스를 사용하여 파일을 S3의 "consultation-images" 디렉토리(또는 버킷 내 경로)에 업로드합니다.
        // 업로드 후, S3에 저장된 이미지의 접근 가능한 URL을 반환받습니다.
        String url = s3Uploader.uploadFile(file, "consultation-images");
        // 업로드된 URL을 담은 응답 DTO를 생성합니다.
        ConsultationImageUploadResponseDto responseDto = new ConsultationImageUploadResponseDto(url);
        // HTTP 200 OK 상태 코드와 함께 응답 DTO를 클라이언트에 반환합니다.
        return ResponseEntity.ok(responseDto);
    }
}

7. 프론트엔드 구현 예시 (JavaScript, SockJS, STOMP.js)

클라이언트 개발에는 웹 표준 기술인 HTML, CSS, JavaScript를 사용하고, 백엔드에서 설정한 웹소켓/STOMP 프로토콜에 맞춰 SockJSSTOMP.js 라이브러리를 활용했습니다.

9.1. 기본 HTML 구조 (참고용)

HTML

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Redis 실시간 채팅</title>
    <style>
        /* CSS는 생략합니다. */
    </style>
</head>
<body>
    <div id="chat-container">
        <h1>Redis 실시간 채팅 예제</h1>

        <div id="user-input">
            <input type="text" id="username-input" placeholder="사용자 이름 입력">
            <button id="set-username-btn">사용자 이름 설정</button>
        </div>

        <div id="room-selection">
            <select id="room-id-select">
                </select>
            <input type="text" id="new-room-id-input" placeholder="새 방 ID">
            <input type="text" id="new-room-name-input" placeholder="새 방 이름">
            <button id="create-room-btn">새 방 생성</button>
            <button id="join-room-btn">채팅방 입장</button>
        </div>

        <div id="chat-room" style="display: none;">
            <h2 id="current-room-name"></h2>
            <div id="chat-messages">
                </div>
            <div id="message-input-area">
                <input type="text" id="message-input" placeholder="메시지를 입력하세요...">
                <button id="send-button">전송</button>
                <button id="leave-button">나가기</button>
            </div>
        </div>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="js/chat.js"></script>
</body>
</html>

9.2. JavaScript 클라이언트 로직 (참고용)

JavaScript

// js/chat.js

const API_BASE_URL = 'http://localhost:8080/chat'; // 백엔드 REST API 기본 URL
const WS_BASE_URL = 'http://localhost:8080/ws/chat'; // 백엔드 WebSocket 엔드포인트 URL

let stompClient = null; // STOMP 클라이언트 객체: 서버와 STOMP 메시지를 주고받는 핵심
let username = '';      // 현재 사용자 이름
let currentRoomId = ''; // 현재 접속한 채팅방 ID
let currentRoomName = ''; // 현재 접속한 채팅방 이름

// --- DOM 요소 참조 ---
const usernameInput = document.getElementById('username-input');
const setUsernameBtn = document.getElementById('set-username-btn');
const roomSelect = document.getElementById('room-id-select');
const newRoomIdInput = document.getElementById('new-room-id-input');
const newRoomNameInput = document.getElementById('new-room-name-input');
const createRoomBtn = document.getElementById('create-room-btn');
const joinRoomBtn = document.getElementById('join-room-btn');
const chatRoomDiv = document.getElementById('chat-room');
const currentRoomNameH2 = document.getElementById('current-room-name');
const chatMessagesDiv = document.getElementById('chat-messages');
const messageInput = document.getElementById('message-input');
const sendButton = document.getElementById('send-button');
const leaveButton = document.getElementById('leave-button');

// --- 이벤트 리스너 설정 ---
setUsernameBtn.addEventListener('click', () => {
    const inputUsername = usernameInput.value.trim();
    if (inputUsername) {
        username = inputUsername;
        alert(`사용자 이름 설정 완료: ${username}`);
        usernameInput.disabled = true;
        setUsernameBtn.disabled = true;
        document.getElementById('room-selection').style.display = 'block'; // 채팅방 선택 UI 활성화
    } else {
        alert('사용자 이름을 입력해주세요.');
    }
});

createRoomBtn.addEventListener('click', async () => {
    const newRoomId = newRoomIdInput.value.trim();
    const newRoomName = newRoomNameInput.value.trim();
    if (!newRoomId || !newRoomName) {
        alert('새 방 ID와 이름을 모두 입력해주세요.');
        return;
    }
    try {
        // Axios를 이용한 REST API 호출: 새 채팅방 생성
        await axios.post(`${API_BASE_URL}/room`, null, { params: { roomId: newRoomId, roomName: newRoomName } });
        alert(`채팅방 '${newRoomName}' (ID: ${newRoomId}) 생성 완료!`);
        await loadChatRooms(); // 방 목록 새로고침
        newRoomIdInput.value = ''; // 입력 필드 초기화
        newRoomNameInput.value = '';
    } catch (error) {
        console.error('채팅방 생성 오류:', error);
        alert('채팅방 생성에 실패했습니다. (이미 존재하는 ID일 수 있습니다)');
    }
});

joinRoomBtn.addEventListener('click', () => {
    const selectedRoomId = roomSelect.value;
    const selectedRoomName = roomSelect.options[roomSelect.selectedIndex].text;

    if (!username) {
        alert('먼저 사용자 이름을 설정해주세요.');
        return;
    }
    if (!selectedRoomId) {
        alert('입장할 채팅방을 선택해주세요.');
        return;
    }

    // 이미 다른 채팅방에 연결되어 있다면 먼저 연결을 끊습니다. (새로운 방 입장 시)
    if (stompClient && stompClient.connected) {
        disconnect();
    }

    currentRoomId = selectedRoomId;
    currentRoomName = selectedRoomName;
    connect(); // WebSocket 연결 및 STOMP 구독 시작
});

sendButton.addEventListener('click', sendMessage);
// Enter 키를 눌렀을 때 메시지 전송
messageInput.addEventListener('keypress', (event) => {
    if (event.key === 'Enter') {
        sendMessage();
    }
});

leaveButton.addEventListener('click', () => {
    if (confirm('정말로 채팅방을 나가시겠습니까?')) {
        disconnect(); // 웹소켓 연결 해제 및 퇴장 메시지 전송
        // UI 초기화
        chatRoomDiv.style.display = 'none'; // 채팅방 UI 숨김
        chatMessagesDiv.innerHTML = ''; // 메시지 목록 초기화
        currentRoomId = '';
        currentRoomName = '';
        currentRoomNameH2.textContent = '';
        document.getElementById('room-selection').style.display = 'block'; // 방 선택 UI 다시 표시
        usernameInput.disabled = false; // 사용자 이름 입력 활성화
        setUsernameBtn.disabled = false;
        usernameInput.value = ''; // 사용자 이름 초기화
        username = '';
    }
});

// --- 웹소켓 (SockJS) 및 STOMP 통신 로직 ---

/**
 * 웹소켓 연결을 설정하고 STOMP 클라이언트를 통해 서버에 연결합니다.
 */
function connect() {
    // 1. SockJS를 사용하여 웹소켓 연결 객체를 생성합니다.
    // 백엔드의 /ws/chat 엔드포인트로 연결을 시도하며, 웹소켓이 지원되지 않는 경우
    // SockJS가 자동으로 HTTP 폴링/스트리밍 등 폴백 메커니즘을 사용합니다.
    const socket = new SockJS(WS_BASE_URL);
    
    // 2. SockJS 연결 위에 STOMP 클라이언트를 생성합니다.
    // STOMP.js 라이브러리가 STOMP 프로토콜을 사용하여 메시지를 주고받는 기능을 제공합니다.
    stompClient = Stomp.over(socket);

    // STOMP 디버그 메시지 비활성화 (개발 중에는 유용하지만, 프로덕션에서는 시끄러울 수 있음)
    // stompClient.debug = null; 

    // 3. STOMP 서버에 연결합니다.
    stompClient.connect({}, (frame) => {
        // 연결 성공 시 콜백 함수
        console.log('Connected: ' + frame);
        chatRoomDiv.style.display = 'block'; // 채팅방 UI 표시
        currentRoomNameH2.textContent = `현재 채팅방: ${currentRoomName}`; // 현재 방 이름 설정
        document.getElementById('room-selection').style.display = 'none'; // 방 선택 UI 숨김

        // 4. 특정 채팅방 채널을 구독합니다.
        // 백엔드에서 이 채널(예: /sub/chat/room/room1)로 메시지를 발행하면 이 클라이언트가 수신합니다.
        stompClient.subscribe(`/sub/chat/room/${currentRoomId}`, (message) => {
            // 메시지를 받으면 화면에 표시하는 showMessage 함수 호출
            showMessage(JSON.parse(message.body));
        });

        // 5. 채팅방 입장 메시지 전송 (서버에 알림)
        // /pub/chat/message 경로로 ENTER 타입의 메시지를 보냅니다.
        // 메시지 내용은 서버에서 "XXX님이 입장하셨습니다." 와 같이 생성됩니다.
        stompClient.send(`/pub/chat/message`, {}, JSON.stringify({
            type: 'ENTER',
            roomId: currentRoomId,
            sender: username,
            message: '' 
        }));

        // 6. 이전 채팅 기록 불러오기 (REST API 호출)
        loadChatHistory(currentRoomId);

    }, (error) => {
        // 연결 실패 시 콜백 함수
        console.error('STOMP Error:', error);
        alert('채팅 서버 연결에 실패했습니다. (새로고침 후 다시 시도하거나, 서버 상태 확인)');
        chatRoomDiv.style.display = 'none'; // 채팅방 UI 다시 숨김
        document.getElementById('room-selection').style.display = 'block'; // 방 선택 UI 다시 표시
    });
}

/**
 * 웹소켓 연결을 끊고 채팅방을 나갑니다.
 */
function disconnect() {
    if (stompClient !== null) {
        // 퇴장 메시지 전송 (서버에 알림)
        stompClient.send(`/pub/chat/message`, {}, JSON.stringify({
            type: 'QUIT',
            roomId: currentRoomId,
            sender: username,
            message: '' 
        }));
        // STOMP 연결 해제
        stompClient.disconnect(() => {
            console.log("Disconnected from STOMP server");
        });
    }
}

/**
 * 메시지 입력 필드의 내용을 가져와 STOMP를 통해 서버에 전송합니다.
 */
function sendMessage() {
    const messageContent = messageInput.value.trim();
    if (messageContent && stompClient) {
        // ChatMessage 객체 형태로 메시지 데이터 구성
        const chatMessage = {
            type: 'TALK', // 메시지 타입: 일반 채팅
            roomId: currentRoomId,
            sender: username,
            message: messageContent,
            timestamp: new Date().getTime() // 현재 시간을 Unix 타임스탬프로 추가
        };
        // STOMP를 통해 메시지 발행: /pub/chat/message 경로로 JSON 문자열 전송
        stompClient.send(`/pub/chat/message`, {}, JSON.stringify(chatMessage));
        messageInput.value = ''; // 입력 필드 초기화
    } else if (!messageContent) {
        alert('메시지를 입력해주세요.');
    }
}

/**
 * 수신된 채팅 메시지를 화면에 표시합니다.
 * 메시지 타입(입장/퇴장/채팅)에 따라 다르게 렌더링하고,
 * 내 메시지와 다른 사람의 메시지를 구분하여 표시합니다.
 * @param {object} chatMessage 수신된 ChatMessage 객체
 */
function showMessage(chatMessage) {
    const messageElement = document.createElement('div');
    messageElement.classList.add('message-row'); // 모든 메시지 행에 기본 클래스 추가

    const timestamp = new Date(chatMessage.timestamp).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });

    // 메시지 타입에 따른 렌더링 분기
    if (chatMessage.type === 'ENTER' || chatMessage.type === 'QUIT') {
        // 시스템 메시지 (입장/퇴장)
        messageElement.classList.add('system-message');
        messageElement.textContent = `${chatMessage.message} [${timestamp}]`;
    } else {
        // 일반 채팅 메시지 (TALK)
        const messageBubble = document.createElement('span');
        messageBubble.classList.add('message-bubble');
        messageBubble.textContent = chatMessage.message;

        if (chatMessage.sender === username) {
            // 내가 보낸 메시지
            messageElement.classList.add('my-message');
            // 내 메시지는 시간과 함께 오른쪽 정렬
            messageElement.innerHTML = `<div>${timestamp}</div>`; 
            messageElement.appendChild(messageBubble); // 말풍선 추가
            
        } else {
            // 다른 사람이 보낸 메시지
            messageElement.classList.add('other-message');
            // 다른 사람 메시지는 발신자, 말풍선, 시간 순서로 왼쪽 정렬
            messageElement.innerHTML = `<strong>${chatMessage.sender}</strong><div>${messageBubble.outerHTML}</div><div style="font-size: 0.8em; color: #777;">${timestamp}</div>`;
        }
    }
    chatMessagesDiv.appendChild(messageElement); // 메시지 목록에 추가
    chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight; // 스크롤을 항상 최신 메시지 위치로 이동
}

// --- REST API 호출 로직 (Axios 활용) ---

/**
 * 백엔드 REST API를 통해 현재 활성화된 채팅방 목록을 불러와 드롭다운에 표시합니다.
 */
async function loadChatRooms() {
    try {
        const response = await axios.get(`${API_BASE_URL}/rooms`);
        const rooms = response.data; // 서버에서 받은 방 목록 (Map 형태)
        roomSelect.innerHTML = '<option value="">-- 채팅방 선택 --</option>'; // 기존 옵션 초기화
        // Map의 각 엔트리를 순회하며 드롭다운 옵션으로 추가
        for (const roomId in rooms) {
            if (rooms.hasOwnProperty(roomId)) {
                const option = document.createElement('option');
                option.value = roomId;
                option.textContent = rooms[roomId]; // 방 이름이 옵션 텍스트로
                roomSelect.appendChild(option);
            }
        }
    } catch (error) {
        console.error('채팅방 목록 불러오기 오류:', error);
        alert('채팅방 목록을 불러오는데 실패했습니다.');
    }
}

/**
 * 특정 채팅방의 이전 채팅 기록을 백엔드 REST API를 통해 불러와 화면에 표시합니다.
 * @param {string} roomId 기록을 불러올 채팅방 ID
 */
async function loadChatHistory(roomId) {
    try {
        const response = await axios.get(`${API_BASE_URL}/history/${roomId}`);
        const historyMessages = response.data; // 서버에서 받은 이전 메시지 목록
        chatMessagesDiv.innerHTML = ''; // 기존 메시지 (혹시 모를) 초기화
        historyMessages.forEach(msg => {
            // 데이터베이스에서 불러온 메시지 타입은 Enum.STRING으로 저장되어 있어 대문자이므로,
            // ChatMessage 모델의 type (enum)에 맞춰toUpperCase()를 사용하여 일치시킵니다.
            const chatMsg = {
                type: msg.type.toUpperCase(), 
                roomId: msg.roomId,
                sender: msg.sender,
                message: msg.message,
                timestamp: msg.timestamp
            };
            showMessage(chatMsg); // 화면에 메시지 표시
        });
    } catch (error) {
        console.error('채팅 기록 불러오기 오류:', error);
        alert('채팅 기록을 불러오는데 실패했습니다.');
    }
}

// --- 초기 로드 및 UI 상태 설정 ---
document.addEventListener('DOMContentLoaded', () => {
    loadChatRooms(); // 페이지 로드 시 채팅방 목록 미리 불러오기
    // 초기에는 사용자 이름 입력 부분만 보이고, 채팅방 및 방 선택 UI는 숨김
    chatRoomDiv.style.display = 'none';
    document.getElementById('room-selection').style.display = 'none';
});

최종 정리: 클라이언트부터 Redis Pub/Sub까지

지금까지 살펴본 코드들을 기반으로, 클라이언트와 백엔드 간에 실시간 상담 채팅 메시지가 어떻게 전송되고 처리되는지 전체적인 흐름을 자세히 설명해 드리겠습니다.

클라이언트 웹소켓 연결 요청:

1. 클라이언트 WebSocket 연결 요청

  • 프론트엔드(index.html, chat.js 등)는 먼저 SockJS 라이브러리를 사용하여 백엔드의 /ws/stomp 엔드포인트로 WebSocket 연결을 시도합니다.

  • 연결이 성공적으로 수립되면, STOMP.js 라이브러리를 사용하여 stompClient.connect() 메서드를 호출하며 STOMP 프로토콜 핸드셰이크를 수행합니다.

  • 이후 클라이언트는 자신이 참여하고 있는 상담방의 메시지를 받기 위해 /sub/chat/room/{consultationRoomId} 채널을 구독(Subscribe)합니다.

  • 동시에, 상담방에 처음 입장했음을 알리는 메시지나 초기화 메시지를 /pub/consultation/chat 엔드포인트로 발행(Publish)합니다.

  • 또한, 실시간 메시지 수신과는 별개로 /api/v1/consultation/history/{consultationRoomId} REST API를 호출하여 이전 상담 채팅 기록을 불러와 화면에 표시합니다. 이는 사용자가 상담방에 진입했을 때 과거 대화 내용을 볼 수 있도록 하기 위함입니다.

2. 메시지 전송 (클라이언트 -> 백엔드)

  • 클라이언트가 메시지를 입력하고 전송 버튼을 누르면, STOMP.js를 통해 /pub/consultation/chat으로 STOMP PUBLISH 프레임이 전송됩니다. 이 프레임 안에는 ConsultationMessageDto 형태의 채팅 메시지 데이터가 담겨 있습니다.

  • 이때, Spring Boot 애플리케이션의 ConsultationMessageController에 정의된 @MessageMapping("/chat") (전체 경로는 /pub/consultation/chat) 메서드가 이 STOMP 메시지를 수신합니다.

  • 컨트롤러는 수신된 ConsultationMessageDtoWebSocket 메시지 객체를 받아 처리합니다. 특히, SimpMessageHeaderAccessor를 통해 WebSocket 세션에 저장된 사용자 정보를 추출하여 메시지의 발신자 정보를 검증하고, DTO에 최종 사용자 ID를 주입하여 메시지 데이터의 신뢰성을 확보합니다.

3. 메시지 처리 (백엔드 ConsultationMessageServiceRedis)

  • ConsultationMessageController는 수신하고 가공한 ConsultationMessageDtoConsultationMessageService에 전달합니다.

  • ConsultationMessageService 는 두 가지 핵심 작업을 동시에 수행합니다:

  • Redis Pub/Sub 발행 (Publish):
    RedisPublisher의 publish() 메서드를 호출하여 메시지를 Redis Pub/Sub의 해당 consultationRoom:{consultationRoomId} 채널에 발행(Publish)합니다. 이 과정에서 ConsultationMessageDto 객체는 JSON 문자열로 직렬화되어 Redis로 전송됩니다.

  • 관계형 데이터베이스 영구 저장:
    consultationMessageService.saveMessage() (내부적으로 consultationMessageRepository.save())를 호출하여 메시지를 관계형 데이터베이스에 영구 저장합니다. 이로써 서버가 재시작되거나 클라이언트가 재접속하더라도 이전 채팅 기록을 불러올 수 있게 됩니다.

4. 메시지 수신 및 브로드캐스팅 (Redis -> 백엔드 -> 클라이언트)

  • Redis Pub/Sub에 발행된 메시지는 Redis에 연결된 모든 백엔드 서버 인스턴스에 실시간으로 전달됩니다. 이는 분산 환경에서 여러 서버가 동일한 메시지를 수신하여 처리할 수 있도록 하는 핵심 메커니즘입니다.

  • RedisSubscriber 수신: 각 백엔드 서버에 설정된 RedisMessageListenerContainer에 등록된 RedisSubscriber.onMessage() 메서드가 이 메시지를 수신합니다.

  • 메시지 역직렬화: RedisSubscriber는 수신된 메시지 본문(JSON 문자열)을 ObjectMapper를 사용하여 ConsultationMessageDto 객체로 역직렬화(복원)합니다.

  • WebSocket 브로드캐스트: 역직렬화된 ConsultationMessageDto 객체를 SimpMessagingTemplateconvertAndSend() 메서드를 호출하여, 해당 채팅방(consultationRoomId)을 구독하고 있는 모든 WebSocket 클라이언트에게 메시지를 브로드캐스트(Broadcast)합니다. 이때 사용되는 WebSocket 목적지 경로는 /sub/chat/room/{consultationRoomId} 입니다.

5. 클라이언트 메시지 표시

  • 클라이언트는 자신이 구독한 /sub/chat/room/{consultationRoomId} 채널로 새로운 메시지를 수신합니다. STOMP.js는 이 메시지를 콜백 함수로 전달하고, 클라이언트의 showMessage()(또는 이런 역할을 하는) 함수는 수신된 메시지를 파싱하여 웹 페이지에 실시간으로 표시합니다.

  • 이러한 과정을 통해, 사용자는 여러 대의 서버 인스턴스 뒤에서 동작하는 분산 시스템 환경에서도 끊김 없는 실시간 채팅 경험을 제공받게 됩니다. Redis Pub/Sub은 서버 간 메시지 동기화를 담당하며, 웹소켓은 클라이언트와의 실시간 연결을 유지하는 핵심적인 역할을 수행합니다.

결론: 아키텍처 설계는 끊임없는 고민의 연속

지금까지 Redis Pub/SubSpring Boot WebSocket을 활용한 실시간 채팅 서비스 아키텍처 설계부터 백엔드 구현 과정, 프론트엔드 예시까지 살펴보았습니다.

이 프로젝트를 통해 제가 얻은 가장 큰 교훈은 아키텍처 설계는 정답이 있는 것이 아니라, 요구사항과 제약 조건, 그리고 기술적 트레이드오프 사이에서 최적의 균형점을 찾아가는 끊임없는 고민의 과정이라는 것입니다.

  • 실시간성: WebSocketSTOMP를 통해 클라이언트와 서버 간의 양방향 통신 채널을 제공했습니다.

  • 확장성: Redis Pub/Sub은 여러 백엔드 서버 인스턴스 간의 메시지 공유를 가능하게 하여, 시스템의 수평적 확장을 용이하게 했습니다.

  • 영속성: 관계형 데이터베이스(JPA)를 통해 채팅 기록을 안전하게 보존하고, 이전 기록 조회 기능을 구현할 수 있었습니다.

  • 호환성: SockJS는 구버전 환경에서의 웹소켓 연결 안정성을 높여주었습니다.

  • 책임 분리: 실시간 메시징은 웹소켓/STOMP로, 일반 데이터 조회 및 관리는 REST API로 분리하여 각 컴포넌트의 역할을 명확히 하도록 노력했습니다.

물론, 이 아키텍처가 완벽하다고 할 수는 없습니다. 대규모 트래픽 처리, 더 복잡한 채팅 기능(파일 전송, 읽음 확인), 인증/권한 부여, 에러 처리 고도화 등 더 많은 고민과 개선의 여지가 남아있습니다. 하지만 이 설계는 견고한 실시간 채팅 시스템의 기반을 다지는 데 충분하다고 생각합니다.

이 글이 실시간 채팅 서비스 구현에 도전하는 분들, 혹은 아키텍처 설계에 대해 고민하는 분들에게 작은 영감과 실질적인 도움이 되기를 바랍니다. 긴 글 읽어주셔서 감사합니다!

profile
매일매일 조금씩 성장하는 개발자

1개의 댓글

comment-user-thumbnail
2025년 7월 3일

글 아주 잘 읽었습니다! 감사합니다.
저는 nodejs 기반 백엔드 서버 에서 개발하면서 Socket.IO + Redis + 메시징큐를 사용했었는데, SockJS + STOMP 방식 + Redis Pub/Sub으로 구현하는 방식을 알게 되었네요. 조금 더 간결해보이기도 합니다.

답글 달기