1. 도입부: 다중 서버 환경에서 웹소켓의 뼈아픈 한계
실시간 채팅 서비스를 개발할 때 가장 먼저 선택하는 기술은 단연 웹소켓(WebSocket)과 STOMP 프레임워크다. 로컬 개발 환경에서 단일 서버로 구동할 때는 스프링 내부 메모리에 로드되는 SimpleBroker만으로도 실시간 메시지가 완벽하게 공유된다.
하지만 실제 서비스 배포 단계로 넘어가 트래픽을 감당하기 위해 서버를 2대 이상으로 스케일 아웃(Scale-out)하는 순간, 예상치 못한 치명적인 결함이 발생한다.
⚠️ 웹소켓 세션의 치명적인 한계
웹소켓은 HTTP와 달리 Stateful(연결 지향형) 프로토콜이다. 클라이언트가 특정 서버에 한 번 웹소켓 연결을 수립하면, 계속 그 서버와 연결 세션을 유지하게 된다.

이 문제를 해결하기 위해 여러 WAS 서버들이 공통으로 바라볼 수 있는 중앙 메시지 허브가 필요하다. 그 허브 역할을 가볍고 고성능으로 처리해주는 것이 바로 Redis Pub/Sub (Publish/Subscribe) 모델이다.
이번 가이드에서는 Lettuce 커넥션 팩토리와 StringRedisTemplate을 활용하여 Redis의 "chat"이라는 공용 채널을 구독하고, 수신된 메시지를 각 서버의 가상 소켓 토픽으로 전송하는 분산 채팅 시스템을 하나씩 정밀 분석해 보자.
2. 전체 구조 조망: Redis Pub/Sub 아키텍처
우리가 구축할 실시간 채팅 시스템은 클라이언트의 요청이 WAS를 거쳐 Redis로 올라갔다가, 구독 중인 모든 WAS로 다시 복제 분배되어 최종 수신자에게 배달되는 구조를 가진다.

이 구조를 사용하면 서버가 10대, 100대로 늘어나더라도 어떠한 세션 단절 현상도 겪지 않고 실시간으로 동기화된 메시징 처리를 수행할 수 있다.
3. 핵심 컴포넌트 설계 및 정밀 코드 해부
이제 이 완벽한 연동 시스템을 만들기 위해 스프링 부트에 설정할 3가지 파일의 구현 세부사항을 파헤쳐보자.
① RedisConfig.java : 인프라 계층 설정
Redis와 통신하기 위한 커넥션을 팩토리를 열고, 데이터를 안전하게 직렬화하여 송수신할 템플릿과 메시지를 항상 감시하고 전달할 컨테이너를 한데 묶는 장치다.
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
/**
* 1. Redis Connection Factory 생성
* Lettuce는 Netty 기반의 비동기 리액티브 라이브러리로, 고성능 처리에 적합하다.
* @Qualifier("chatPubSub")를 부여하여 채팅 전용 풀을 명확히 구분한다.
*/
@Bean
@Qualifier("chatPubSub")
public RedisConnectionFactory chatPubSubFactory() {
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
configuration.setHostName(host);
configuration.setPort(Integer.parseInt(port));
// 참고: Redis Pub/Sub은 영속성 데이터를 조작하는 것이 아니라 인메모리 스트림 형식이므로,
// 특정 데이터베이스 번호(Database index)에 의존적이지 않다. 따라서 셋업을 주석처리한다.
// configuration.setDatabase(0);
return new LettuceConnectionFactory(configuration);
}
/**
* 2. 메시지 발행(Publish)을 위한 StringRedisTemplate
* 일반적인 Object 대신 텍스트로 전환된 JSON 혹은 일반 스트링 값을 Key-Value로 전송하기 위해
* 직렬화 로직이 안전하게 탑재된 StringRedisTemplate을 사용한다.
*/
@Bean
@Qualifier("chatPubSub")
public StringRedisTemplate stringPubSubTemplate(@Qualifier("chatPubSub") RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
/**
* 3. 특정 Redis 채널을 실시간 감시하는 컨테이너 (Subscriber)
* Redis로부터 메시지가 인입되는지 연결팩토리를 통해 상시 귀를 기울이고 있다가,
* 특정 채널(여기선 "chat")로 전달되면 등록된 어댑터로 처리를 우회시킨다.
*/
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
@Qualifier("chatPubSub") RedisConnectionFactory connectionFactory,
MessageListenerAdapter messageListenerAdapter
) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
// "chat" 이라는 패턴의 토픽 채널을 리스너 어댑터와 묶어 등록한다.
container.addMessageListener(messageListenerAdapter, new PatternTopic("chat"));
return container;
}
/**
* 4. 가로챈 메시지를 처리할 타겟 클래스와 타겟 메서드 연결 어댑터
* redisMessageListenerContainer에 감지된 페이로드를 전달받아,
* 비즈니스 단을 처리하는 'RedisPubSubService' 내부의 'onMessage' 메서드로 던져준다.
*/
@Bean
public MessageListenerAdapter messageListenerAdapter(RedisPubSubService redisPubSubService) {
// 첫 번째 인자는 리스너를 구현한 서비스 빈이며, 두 번째 인자는 매핑할 콜백 타겟 메서드 명칭이다.
return new MessageListenerAdapter(redisPubSubService, "onMessage");
}
}
② RedisPubSubService.java : 비즈니스 계층 처리
인프라가 물어다 준 바이너리 메시지를 파싱하고, 스프링 소켓 프레임워크인 SimpMessageSendingOperations를 이용해 실제 클라이언트가 매달려 있는 논리적인 웹소켓 채널로 메시지를 최종 발송하는 가교 역할이다.
@Service
public class RedisPubSubService implements MessageListener {
private final StringRedisTemplate stringRedisTemplate;
private final SimpMessageSendingOperations messageTemplate;
// @Qualifier 어노테이션으로 우리가 RedisConfig에 생성한 전용 pub/sub 템플릿을 정밀하게 주입받는다.
public RedisPubSubService(
@Qualifier("chatPubSub") StringRedisTemplate stringRedisTemplate,
SimpMessageSendingOperations messageTemplate
) {
this.stringRedisTemplate = stringRedisTemplate;
this.messageTemplate = messageTemplate;
}
/**
* 외부(StompController 등)에서 가공된 메시지를 Redis의 특정 물리 채널에 퍼블리시할 때 호출한다.
*/
public void publish(String channel, String message) {
stringRedisTemplate.convertAndSend(channel, message);
}
/**
* Redis Subscription을 타고 우리 WAS 서버로 날아온 메시지를 수신했을 때 수행할 동작이다.
* (MessageListener 상속 및 어댑터 매핑 콜백 메서드)
*/
@Override
public void onMessage(Message message, byte[] pattern) {
// 1. 바이트 배열의 페이로드를 자바 스트링 텍스트로 복원
String payload = new String(message.getBody());
ObjectMapper objectMapper = new ObjectMapper();
ChatMessageDto chatMessageDto = null;
try {
// 2. 직렬화된 JSON 문자열 데이터를 애플리케이션 DTO 객체로 역직렬화 수행
chatMessageDto = objectMapper.readValue(payload, ChatMessageDto.class);
// 3. 스프링 웹소켓의 메시지 브로커를 활용해 해당 방 번호(/topic/{roomId})를
// 구독 중인 실제 엔드유저 클라이언트들에게 최종 브로드캐스트 전송을 수행한다.
messageTemplate.convertAndSend("/topic/" + chatMessageDto.getRoomId(), chatMessageDto);
} catch (JsonProcessingException e) {
// JSON 변환에 오류가 발생할 경우, 실무에서는 에러 로그를 남기고 데드레터 큐 등으로 복구 조치를 취해야 한다.
throw new RuntimeException("Redis 수신 메시지 디코딩 실패 : " + e.getMessage(), e);
}
}
}
③ StompController.java : 엔드포인트 수문장
클라이언트가 소켓 연결 상태에서 보내온 새로운 채팅 데이터 패킷을 가장 먼저 환대하는 공간이다. 이 컨트롤러는 특정 로컬 웹소켓에 직접 주입하는 책임을 회피하고, 데이터를 공용 Redis 채널로 흘려보내는 트리거를 가동한다.
@Controller
@Slf4j
public class StompController {
private final SimpMessageSendingOperations messageTemplate;
private final ChatService chatService;
private final RedisPubSubService redisPubSubService;
public StompController(
SimpMessageSendingOperations messageTemplate,
ChatService chatService,
RedisPubSubService redisPubSubService
) {
this.messageTemplate = messageTemplate;
this.chatService = chatService;
this.redisPubSubService = redisPubSubService;
}
/**
* [방법 1. 단순 로컬 연동 주석]
* 아래 주석은 서버가 단 하나일 때만 유용한 코드다.
* WAS가 2개 이상일 땐 본인이 구독하고 있는 세션 사용자에게만 한정 배달되기 때문에 사장시킨다.
*/
/*
@MessageMapping("/{roomId}")
@SendTo("/topic/{roomId}")
public String sendMessage(@DestinationVariable String roomId, String message) {
log.info("sendMessage: {}", message);
return message;
}
*/
/**
* [방법 2. 스케일아웃에 최적화된 Redis 연계 라우팅 기법]
* 클라이언트가 WebSocket 프로토콜 상에서 특정 방으로 메시지 전송 시 실행된다.
*/
@MessageMapping("/{roomId}")
public void sendMessage(@DestinationVariable Long roomId, ChatMessageDto chatMessageDto) throws JsonProcessingException {
log.info("수신된 STOMP 메시지: [방번호: {}, 내용: {}]", roomId, chatMessageDto);
// 1. 영속성 유지 및 히스토리 보관을 위해 DB 백엔드 서비스 가동 (채팅 저장 서비스)
chatService.saveMessage(roomId, chatMessageDto);
// 2. 전달받은 DTO 객체에 라우팅 경로에 포함된 가상 Path Variable 데이터를 동기화한다.
chatMessageDto.setRoomId(roomId);
// 3. 다른 포트, 다른 가상장비 서버들에게 전파하기 위해 DTO 객체를 JSON 텍스트 형식으로 변환한다.
ObjectMapper objectMapper = new ObjectMapper();
String message = objectMapper.writeValueAsString(chatMessageDto);
// 4. 로컬 소켓에 즉시 브로드캐스트하지 않고, 중앙 Redis의 "chat" 채널로 메시지를 발행(Publish)한다.
// 이 메시지는 Redis 브로커를 거쳐 다시 복사되어 각 WAS 노드로 순식간에 흘러 들어갈 것이다.
redisPubSubService.publish("chat", message);
}
}
4. 메시지 라이프사이클: 6단계의 순환 흐름 분석
실제 메시지가 어떠한 흐름을 거쳐 발신자로부터 수신자까지 온전히 생존해서 이동하는지, 전체 라이프사이클을 추적해 보자.

📝 흐름 상세 기술 설명
- [1단계] 프론트엔드(Vue.js 등)에서 소켓 인프라를 통해 특정 방(roomId)으로 JSON 데이터 패킷을 발행한다.
- [2단계] WAS의 컨트롤러 수문장(StompController)이 이를 수신하여 DB 저장 등의 백엔드 후처리를 안전하게 마친 후 객체를 가공한다.
- [3단계] 직렬화된 JSON 데이터를 RedisPubSubService.publish()에 주입하여 Redis의 공용 물리 채널 "chat"으로 쏘아 올린다.
- [4단계] Redis 서버가 공용 채널을 청취 중인 분산된 모든 가상 서버의 RedisMessageListenerContainer에게 데이터를 밀어준다.
- [5단계] 각 WAS 내의 MessageListenerAdapter가 이 신호를 낚아채어 지정된 서비스 빈의 onMessage 콜백 비즈니스 로직을 자동 수행시킨다.
- [6단계] onMessage 내부에서 최종적으로 스프링 메시징 웹소켓 브로커를 가동시켜 실제 세션을 유지 중인 브라우저 단말로 메시지를 전달한다.
5. 실무 고려사항 및 고도화 전략
Redis Pub/Sub은 훌륭한 다중 서버 세션 공유 도구이지만, 실무 운영 환경으로 넘어가려면 반드시 아래의 트레이드오프와 아키텍처 한계점을 직시하고 설계해야 한다.
1) Redis Pub/Sub의 'Fire and Forget' 한계
Redis의 Pub/Sub은 "발행 후 방치(Fire and Forget)" 모델이다. 즉, 메시지를 구독 채널로 밀어낸 시점에 수신 대기 중인 WAS 노드가 없다면 해당 메시지는 보관되지 않고 공중에 증발해 버린다.
- 해결 방안: 사용자가 일시적으로 네트워크 환경이 불안정하여 세션이 유실되었거나, WAS 서버가 임시 배포 단계여서 메시지를 캐치하지 못했을 상황을 완벽히 커버해야 한다. 이를 해결하기 위해 이번 코드에서도 볼 수 있듯이 StompController가 메시지를 Redis에 쏘기 직전, chatService.saveMessage()를 통해 관계형/비관계형 데이터베이스에 먼저 이력을 안전하게 기록(영속화)하게끔 유도하는 설계가 아주 정석적인 모범 사례다.
2) 세션 수와 커넥션 관리 (Lettuce의 가치)
웹소켓 세션이 늘어날수록 WAS 내부의 스레드 점유율과 데이터베이스 커넥션 풀(HikariCP) 고갈 우려가 극대화된다.
- 해결 방안: Lettuce 드라이버는 동기식 차단 모델인 Jedis와 달리, Netty 논블로킹 엔진 위에서 돌아가므로 단 한 개의 물리적 TCP 커넥션만으로도 압도적인 수의 동시 처리를 소화해 낸다. 실무에서는 이러한 커넥션 자원을 사수하기 위해 일반 비즈니스 DB 커넥션 풀과 실시간 메시징 소켓 전용 Redis 커넥션 팩토리를 이번 구현체와 같이 @Qualifier를 주입하여 하드웨어 자원적으로 안전하게 분리·격리 운용하는 것이 필수적이다.
6. 결론: 분산 실시간 서비스의 표준 아키텍처
웹소켓과 STOMP 프레임워크는 실시간 양방향 데이터 통신을 구현하는 완벽한 인터페이스를 선사하지만, 다중 서버 인프라(Scale-out)라는 실무 인프라를 만나는 순간 온전한 힘을 발휘하지 못한다.
이번 가이드에서 분석해 본 Redis Pub/Sub은 그러한 분산 장벽을 무너뜨리는 가장 빠르고 효과적인 초경량 브릿지 솔루션이다.
- Lettuce를 바탕으로 논블로킹 고성능 연결 엔진을 열고,
- StringRedisTemplate과 RedisMessageListenerContainer로 채널을 실시간 감청하며,
- MessageListenerAdapter를 이용해 기존 자바 비즈니스 로직 빈에 매끄럽게 인젝션하는 구조는 현대 엔터프라이즈 채팅 서버 아키텍처의 가장 정석적인 표준 모델이다.
세련된 코드로 구성된 이번 메시징 파이프라인 설계를 바탕으로 여러분만의 안정적인 실시간 채팅 플랫폼 구축에 도전해 보길 바란다.