채팅서비스의 메세지 브로커 및 구독 관리에 Redis 를 적용하였다. (+ RefreshToken 저장)
이번 프로젝트를 진행하면서 손에 꼽을 정도 이해하기 힘들었던 주제였던거 같다. 어떤 구조로 동작하는 것인지 도무지 감이 안와서 코드 한참을 들여다 보고, 실제로 실습을 진행해 보고서야 느낌을 알게 됐다. 솔직히 그렇게 복잡하거나 어려운 내용은 아닌데 뭐랄까 속시원하게 설명해주는 레퍼런스가 없던게 문제 같다... 아닌가... 내 배경지식의 탓일 수도 있고. 여튼 시간이 이틀 꼬박 걸렸다.
(사실 여전히 내가 올바르게 적용을 한 것인지도 잘 모르겠다.)
메세지 브로커로서 Redis 의 역할
메세지 브로커는 브로커(broker, 중개인) 라는 단어의 의미 그대로, 메세지를 발행한 곳에서 구독한 곳으로 전달하는 역할을 수행한다. 메세지를 전송하고 수신하는 것의 핵심적인 기능을 수행하는 셈이다. 근데 이 기능은 어떤 로직으로 수행되는 것일까? 당연하게도 메시지 브로커에 발이 달려서 메세지를 손에 쥐고 이곳 저곳을 뛰어다니는 것은 아니고, 옵저버 패턴이라는 디자인 패턴을 통해서 브로커 역할을 수행한다.
옵저버 패턴은 어떤 객체의 목록을 관찰(observe) 하고 있다가 객체에 어떤 변화가 생겼을 때, 그에 상응하는 메서드를 호출하여, 해당 변화를 핸들링하는 패턴을 말한다.
옵저버 패턴
https://velog.io/@octo__/%EC%98%B5%EC%A0%80%EB%B2%84-%ED%8C%A8%ED%84%B4Observer-Pattern
메세지 브로커는 위 옵저버 패턴을 이용하여, 특정 listener 들을 가지고 있다가 해당 리스너에 변화가 생겼을 때 (메세지 수신) 이를 핸들링(메세지 전송) 하는 식으로 작동한다. 언뜻 당연한 이야기 처럼 들리겠지만 Redis 를 적용하고자 레퍼런스들을 한참 읽으면서도 이 부분에 대해서 이해를 못했었는데 옵저버 패턴에 대해서 미리 알았다면 조금 더 이해가 쉬웠을 것 같다.
1. Redis 를 적용하기 전
사실 현재 프로젝트에서는 Redis 가 메세지 브로커로서 동작하든, Redis 를 빼버리든 똑같이 잘 동작한다. 왜냐면 Redis 를 메세지 브로커로 도입하는 것은 다중 서버로 scale up 하는 상황을 고려한 설계인데 현재로서는 단일 서버로 구동되기 때문이다. 그래서 SimpleMessageBroker 라는 메세지 브로커가 실질적 메세지 브로킹을 다 하는 셈이다.
SimpleMessageBroker 는 스프링 서버의 내장 메세지 브로커로, 인메모리에 웹소켓에 접속해 있는 유저들의 세션 정보와 구독 정보를 관리하고 메세지를 브로킹 해준다. Redis 는 이렇게 서버 내부에서 메세지 브로킹을 해주는 것은 아니고 (커스텀을 잘하면 그렇게 만들 수 있을 지도 모르겠다) 서버간의 메세지 브로킹에 한해서 적용된다. 메세지 브로커 설정은 아래와 같다.
/*
* 메시지 브로커 설정
* enableSimpleBroker 설정을 사용하면 SimpleBrokerMessageHandler 를 메세지 브로커로 사용하는데
* ConcurrentHashMap 을 이용하여 세션정보를 메모리에 저장해두고 메시지를 구독/발행 한다.
* Redis 는 STOMP 프로토콜을 지원하지 않기 때문에 서버 자체적으로는 해당 Simple MessageBroker 를 사용하고,
* 서버 간의 메세지 브로킹에 한해서 적용하게 된다. STOMP 프로토콜을 지원하는 RabbitMQ, ActiveMQ 같은 경우
* enableStompBrokerRelay 라는 설정으로 외부의 메세지 브로커를 직접 갖다 쓸 수 있는데,
* 간단한 설정으로 다양한 기술을 사용할 수 있다고 한다.
* 만약 채팅서비스를 더 고도화 시키고자 한다면 도입해볼 여지가 있지만
* 본 프로젝트에서는 기능상 더 다양하게 쓰임이 있는 Redis 를 활용하여 메세지 브로커 기능을 수행하도록 한다.
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("");
}
주석에 써놓았듯이 STOMP 프로토콜을 지원하는 다른 메세지 브로커를 사용하면 서버 내부의 메세지 브로킹에도 직접적으로 적용할 수 있는 것 같다.(확실히 모르겠음)
2. 메세지 브로킹 핵심 코드
다른 자잘한 비지니스 로직은 제외하고 핵심적인 역할을 하는 클래스를 알아보겠다.
/**
* RedisMessageListenerContainer 를 구성합니다.
* RedisMessageListenerContainer 는 Redis 의 Pub/Sub 을 관리하는 컨테이너로,
* 구독 대상이 되는 채널 (ChannelTopic 클래스) 과 해당 채널에 메세지가 발행되었을 때
* 이를 핸들링 하는 메서드(MessageListener) 를 등록해 줄 수 있습니다.
* @param redisConnectionFactory Redis 서버와의 연결 정보
*/
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory) {
RedisMessageListenerContainer listenerContainer = new RedisMessageListenerContainer();
listenerContainer.setConnectionFactory(redisConnectionFactory);
listenerContainer.addMessageListener(new MessageListenerAdapter(this), new PatternTopic("chat"));
return listenerContainer;
}
주석보다 더 좋은 설명을 쓸 수가 없을 것 같다. 앞서 말한 옵저버 패턴을 생각하면서 주석을 읽어보면 어떤 식으로 작동을 하는 지 유추가 될 것이다. 사실상 이 MessageListenerContainer 및 MessageListener 클래스가 Redis 가 '메세지가 발행되었구나' 를 인식하는 창구이며, 메세지를 핸들링하는 메서드이기 때문에 메세지 브로킹은 얘가 다 하는 셈이다.
코드를 설명하자면
- redisConnectionFactory 는 레디스 디비에 연결해주는 기본 설정
- addMessageListener 메서드는 MessageListener 를 등록해주는 메서드
- MessageListenerAdapter 는 후술할 메세지 핸들러 클래스
- PatternTopic 은 구체적인 리스닝 대상 ( "chat" Topic 에다가 메세지를 보내면 얘가 반응한다.)
public class RedisMessageListener implements MessageListener {
private final RedisTemplate<String, WebSocketMessageDto> redisTemplate;
/*
* 메세지를 실제로 발행해 주는 객체 입니다.
* SimpMessageSendingOperations 는 레디스의 구독 정보와 상관 없이
* 스프링 웹소켓의 심플 메세지 브로커에 따라서 메세지를 전송합니다.
*/
private final SimpMessagingTemplate messageSender;
@Override
public void onMessage(Message message, byte[] pattern) {
WebSocketMessageDto messageDto = (WebSocketMessageDto) redisTemplate.getValueSerializer().deserialize(message.getBody());
messageSender.convertAndSend("/sub/channel/" + messageDto.getChannelId(), messageDto);
}
}
실제로 메세지를 발행 해주는 클래스이다. 이걸 MessageListenerAdapter 에 넣어서 완성된 메세지 리스너 객체로 만들어주면 RedisMessageListenerContainer 에 인자로 넣어줄 수 있게 된다. Adapter 에 넣어줄 때 기본적으로 onMessage 가 메세지를 처리하는 메서드로 지정이 되는데, 다른 메서드가 처리하도록 별로도 메서드 명을 지정해 줄 수도 있다. 이런거 보면 꽤나 자유도를 높여놓은 것 같다는 생각이 든다.
onMessage 메서드를 간략하게 설명하자면,
- RedisTemplate<String, WebSocketMessageDto> : 이거는 왠지 저장할 일이 있을 것 같아서 빈으로 등록해주었는데, 별 쓸모는 없었고 여기서 시리얼라이저를 갖다 쓸 수 있었다... 참고로 레디스는 모든 데이터를 바이트 배열로 저장해주기 때문에 시리얼라이저 설정을 해주어야 하는데 (LocalDateTime 같은 것을 저장하려면 별도 의존성을 추가하든지 커스텀 시리얼라이저를 만들어주어야 한다.) 메세지에 담긴 바이트 배열을 다시 객체화 해주었다.
- SimpMessagingTemplate 주석에 써있듯이 얘는 Redis 와 상관없다. 이걸로 메세지를 보내면 SimpleMessageBroker 를 통해 메세지를 구독자에게 전달해준다.
흐름을 이해가 가는가?? 그러니까 서버마다 저 메세지 리스너를 등록해두고, onMessage 메서드로 SimpleMessageBroker 에 메세지를 밀어넣도록 해주면 다른 서버에서 발행된 메세지도 여기서 onMessage 를 타고 구독자에게 뿌려지게 되는 것이다.
실제로 레디스를 썼을 때와 안썼을 때의 코드는 다음의 차이밖에 없다.
Redis 적용 전
@Service
@ConditionalOnProperty(name = "redis-config.websocket", havingValue = "false")
@RequiredArgsConstructor
public class BasicWebSocketService implements WebSocketService {
private final SimpMessagingTemplate simpMessagingTemplate;
private final String CHANNEL_PREFIX = "/sub/channel/";
@Override
public void publishMessage(String channelId, Object message) {
simpMessagingTemplate.convertAndSend(CHANNEL_PREFIX + channelId, message);
}
@Override
public void subscribeChannel(String channelId, Long userId) {
/* SimpleMessageBroker 를 사용하고 있으므로 이전의 로직에서 세션이 저장됩니다. */
WebSocketMessageDto subscribingMessage = WebSocketMessageDto.builder()
.messageType(MessageType.ENTER)
.channelId(Long.valueOf(channelId))
.userId(userId)
.content("채널에 입장하였습니다.").build();
simpMessagingTemplate.convertAndSend(CHANNEL_PREFIX + channelId, subscribingMessage);
}
}
Redis 적용 후
@Service
@ConditionalOnProperty(name = "redis-config.websocket", havingValue = "true")
@RequiredArgsConstructor
public class RedisWebSocketService implements WebSocketService {
private final RedisTemplate<String, Object> redisTemplate;
public void publishMessage(String channelId, Object message) {
redisTemplate.convertAndSend("chat", message);
}
public void subscribeChannel(String channelId, Long userId) {
WebSocketMessageDto messageDto = WebSocketMessageDto.builder()
.messageType(MessageType.ENTER)
.userId(userId)
.channelId(Long.valueOf(channelId))
.content("채널에 입장하였습니다.").build();
redisTemplate.convertAndSend("chat", messageDto);
}
}
잘 보면 Redis 의 메세지 리스너로 발행 정보를 보내주느냐, 아니면 직접 SimpleMessageTemplate 으로 서버 내부에 직접 메세지를 보내주느냐 하는 것이다. 나는 이러한 구조도 모르고 대체 Redis 는 구독자를 어떻게 식별하는 것인지 찾고 있었다. 그러니 한참 걸리지...
코드를 조금 설명하면
- RedisTemplate 의 convertAndSend 메서드는 메세지를 아까 등록해 두었던 MessageListener 로 전달하는 역할을 한다. 아까 PatternTopic 을 생성할 때 "chat" 으로 등록했었다. (참고로 PatternTopic 은 이름에서 알 수 있듯이 chat.sports 이런식으로 패턴으로 구분하여 Listen 한다. 이외에 ChannelTopic 이라는 애도 있다.)
- BasicWebSocketService 는 아까 Listener 의 onMessage 에서 한 것처럼 SimpleMessagingTemplate 으로 직접 서버 내부의 메세지 브로커를 통해 메세지를 발행해버린다. 다시 말해 서버 외부로 메세지 공유를 할 수 없다.
이걸로 다른 사람들도 구조를 이해를 할 수 있을까? 자세히 내 코드를 공개할 수도 있지만, 내 경우에는 레퍼런스들을 보면서 오히려 비지니스 로직들이 들어간 코드 때문에 이해가 힘들었다. 간단한 구조이니, 핵심적인 코드만 보는게 더 이해가 쉬울 것 같아서 이렇게만 정리해 보았다.
참고로 이제부터 그냥 코드 블럭을 쓰기로 했다. 남들이 엉뚱하게 복붙하는 게 싫다고 했는데 뭔 같잖은 오만 같다. 훨씬 더 깔끔하고 보기 좋다....