개인 플젝을 하면서 채팅을 구현하게 되었고 WebSocket이랑 Stomp를 사용했었고, 그간 공부했던 것을 기록하기 위해서 글을 남긴다!!
전체적인 내용은 https://docs.spring.io/spring-framework/docs/5.3.8/reference/html/web.html#websocket-stomp 공식 레퍼런스와 각종 블로그를 통해 정리한 개인적인 인사이트 이며 오류가 있을수도 있다!
일반적이고 대중적인 그룹 채팅 창을 만드려고 했다
사용자가 채팅 방에 들어갈때마다 연결이 되어야 하고 나오면 연결이 해제가 되어야 한다.
주고받은 메시지를 데이터베이스에 저장해서 가져올 수 있다.
한 명이 채팅을 보냈을때 같은 채팅방에 있는인원들이 채팅을 전부 볼 수 있어야 한다.
기존 Spring 웹 소켓 방식으로는 low level이라 내가 원하는 기능들을 구현 할 수 있는지 의구심이 들었고, 설사 가능 하더라도 지금의 내 레벨에서는 구현하는데 매우 많은 시간이 걸릴거라 생각했다.
단순 websocket을 사용했을때는 1:1 단순 채팅밖에 할 수 없었고 , 세션의 라이프사이클이 언제 끝나고 생성되는지 알기가 힘들었다.
1:1 채팅 밖에 안됌
브라우저가 열려 있는 동안 통신이 이루어지는데 브라우저를 종료했을때 소켓 세션이 끊어지는지 확실하지 않음.
WebSocket이 너무 low -level 기술이라 좀 더 고도화 된 기술이 필요함.
Stomp는 Websocket의 좀더 고도화된 형태로 통신을 하게되는 프로토콜이다.
원래 외부 메시지 브로커에 연결하기 위해 개발된 것이다. stomp를 통해서 최소의 메시징 처리등을 할수가 있게 되었다.
COMMAND
header1:value1
header2:value2
Body^@
Stomp로 통신을 할 때 클라이언트는 SEND나 SUBSCRIBE로 메시지를 전송할수 있게 된다. 메시지를 보내게 되면 외부 메시지 브로커를 통해 다른 클라이언트에게 메시지를 보내거나 , 서버에게 메시지를 보내서 구독 - 발행 구조를 수행할수 있게 된다.
스프링에서는 Stomp로 통신 할 때 간단한 메시지 브로커의 역할을 수행해준다.
커스텀 메시지 프로토콜을 개발할 필요가 없다.
Stomp 클라이언트는 스프링에 있는 자바 클라이언트를 사용 할 수가 있다.
메시지 브로커를 사용해서 구독 및 서버에서 전송하는 메시지를 관리 할 수가 있다.
들어오는 메시지를 컨트롤러를 통해서 관리를 할 수가 있다.
-Spring SimpleBroker를 사용하니 세션의 흐름과 통신이 정확히 어떻게 이루어지는지 파악하기가 힘들었으며 현재 구독 중인 채팅 방과 사용자들을 표현하기가 힘들었다.
위의 문제들을 RabbitMq 외부 메시지 브로커를 이용해서 해결했다.

웹 소켓 클라이언트가 메시지를 Stomp를 통해서 보내게 되면 미리 설정해둔 MessageBroker를 통하게 된다.
이 연결에서 Spring은 TCP를 통해서 메시지를 외부 Stomp MessageBroker로 전달하게 되고 메시지의 처리를 Broker에게 위임하게 된다.
위 그림에서 Spring에서 Controller를 통해서 메시지를 브로커에게 전달한다( 위 그림에서는 SimpAnnotationMethodMessageHandler로 표기되어 있는데 실제로 Controller를 구현할때 @MessageMapping과 같은 어노테이션을 사용해서 전달하게 된다)
어떤 세션이 어떤 채팅방에 연결이 되어있고 현재 상태를 ui를 통해서 확인 할 수 있다.
다양한 형태의 구독 메시지를 설정할 수가 있다.
비동기 메시징

RabbitMq에서는 메시지를 전달받게 되면 먼저 Exchange를 통해서 분류를 하게 된다.
Direct 같은 경우에는 1:1로 매칭되는 연결형태이고 전송받는 수신자에 대해 Queue가 생성이되고 Exchange와 Queue를 하나로 바인딩해서 사용을 하게 된다.
바인딩된 Direct Exchange는 라우팅 키를 이용해서 1:1 통신을 하게 된다.
Topic Exchange는 메시지가 들어오고 각 패턴에 맞는 큐에게 메시지를 전송한다.
큐에서는 패턴에 맞는 메시지를 수신받고 소비하면서 수신자에게 메시지를 전송한다.
Fanout Exchange는 구독을 한 이용자 전원에게 메시지를 전송하는 방식이다.
여기서 내가 사용한 방식은 따로 패턴을 지정할 필요가 없으므로 Fanout Exchange를 사용했다.
RabbitMq는 Stomp 플러그인을 지원하고 Spring과도 잘 맞으므로 Stomp 프로토콜을 통해서 메시지를 전송하게 됐다.
@Bean
public RabbitAdmin rabbitAdmin() {
return new RabbitAdmin(connectionFactory());
}
@Bean
public RabbitTemplate rabbitTemplate() {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory());
rabbitTemplate.setMessageConverter(jsonMessageConverter());
return rabbitTemplate;
}
@Bean
public ConnectionFactory connectionFactory() {
CachingConnectionFactory factory = new CachingConnectionFactory();
factory.setHost("localhost");
factory.setUsername("guest");
factory.setPassword("guest");
return factory;
}
@Bean
public Jackson2JsonMessageConverter jsonMessageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true);
objectMapper.registerModule(dateTimeModule());
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper);
return converter;
}
@Bean
public MappingJackson2MessageConverter consumerJackson2MessageConverter() {
return new MappingJackson2MessageConverter();
}
@Bean
public Module dateTimeModule() {
return new JavaTimeModule();
}
@Bean
public RabbitListenerEndpointRegistry rabbitListenerEndpointRegistry() {
return new RabbitListenerEndpointRegistry();
}
@Bean
public DefaultMessageHandlerMethodFactory messageHandlerMethodFactory() {
DefaultMessageHandlerMethodFactory factory = new DefaultMessageHandlerMethodFactory();
factory.setMessageConverter(consumerJackson2MessageConverter());
return factory;
}
@Override
public void configureRabbitListeners(final RabbitListenerEndpointRegistrar registrar) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setPrefetchCount(1);
factory.setConsecutiveActiveTrigger(1);
factory.setConsecutiveIdleTrigger(1);
factory.setConnectionFactory(connectionFactory());
registrar.setContainerFactory(factory);
registrar.setEndpointRegistry(rabbitListenerEndpointRegistry());
registrar.setMessageHandlerMethodFactory(messageHandlerMethodFactory());
}
RabbitMq에서 각 설정들을 진행했다.
RabbitMessage 템플릿과 전송받은 데이터를 바인딩할 객체들과 설정들을 빈으로 등록해줬다.
RabbitMq에서 Stomp를 사용하기 위해
rabbitmq-plugins enable rabbitmq_stomp
설정을 키고 사용을 해야 했다. 안키고 사용을 하니 포트 할당이나 무언가 문제가 생겨서 사용을 할 수가 없었다.
Stomp는 RabbitMq에 대해 Broker 스펙을 규정하고 있지 않다. 대신 destination 경로 패턴에 따라 특정 스펙을 정할수가 있다.
Subscribe 프레임에 대해서 /exchange/"name"/"pattern" 형태의 패턴을 사용할수가 있다.
name이름을 가진 Exchange을 자동으로 생성하고 pattern명을 가진 큐와 exchange를 바인딩한다.
Send 프레임에 대해서는 /exchange/"name"/"routing-key" name명을 가진 exchange에 대해서 routing key로 라우팅을 한다.


구독을 하니 큐가 자동으로 생성이 됐고 바인딩이 자동으로 됐다!
기존 Spring SimpleMessageBroker도 충분히 사용이 가능하지만 기왕이면 RabbitMq로 사용을 하는게 더 좋다고 생각을 했다.
RabbitMq에서도 패턴이 되게 다양하고 세부 구조에 대해서는 좀 더 심도있는 분석이 필요하다.
사용을 하는것과 그 전체를 이해하는건 결이 다르고 큐와 exchange에 대해서도 여러가지 방법이 있다.
내가 예상했던 대부분의 스펙을 rabbitmq를 통해서 해결할 수 있었다.
그럼에도 불구하고 아직 문제점이 몇가지 남아있다.
먼저 첫번째로 지금은 채팅방을 만들고 그 채팅방 id를 가진 url를 구독을 하게 했는데, 채팅방이 없는 상태에서 연결을 시도하면 당연히 오류가 발생하게 되고 이 오류를 처리하는데 있어서 문서들을 되게 심도있게 읽어야 할것 같다.
exception handling이 생각보다 쉽지는 않은것 같다.
그럼에도 불구하고 외부 브로커를 사용하는것이 좀 더 낫다는것이 내 결론이다.
여담으로

js 진영에서는 socket.io가 제일 많이 사용되는데 java 진영에서는 왜 stomp가 많이 사용되는지도 알아봐야 겠따.