졸업 프로젝트로 게임 서버를 제작하고 있다. 메인 기능은 유저 인증을 거친 뒤에 채팅과 게임 데이터를 주고 받게 하는 것이다.
Spring + Redis를 이용하여 서버를 구축했는데, 다행히도 게임 로직은 서버에서 처리하지는 않고 서버는 데이터를 주고 받기 위한 수단으로 주로 사용하게 되었다.
프로젝트를 완성해가면서 어떠한 설정을 했고 어떻게 작동하는 지 정리를 해보며 복습을 하려고 한다.
Spring에 대해서는 설명할 필요가 없다고 생각이 되며, Redis를 왜 사용했는지 돌이켜 보려고 한다.
NoSQL 저장소 인 것은 알고 있었는데, 다른 NoSQL과 무엇이 다른지 살펴보자.
사실 위의 특징을 다 제치고, Pub/Sub 기능 하나 때문에 Redis를 사용한다. 근데 Pub/Sub 기능을 활용하려면 Kafka나 RabbitMQ등 많은 메세지 브로커들이 존재하는데, 굳이 Redis를 사용 한 이유가 무엇일까?
바로 성능과 Push Based Subscription 방식 때문이다.
먼저, 이벤트를 저장하지 않기 때문에 성능이 엄청 빠르다. 나의 경우에는 Publish를 한 event를 저장할 필요가 없다. 왜냐하면 유니티 클라이언트에 싹 다 publish를 하여서 데이터를 동기화 시킬 목적일 뿐, pub/sub 과정에서 주고 받는 데이터를 Redis에 저장할 필요가 전혀 없다. 하지만 Kafka는 event를 저장하는 방식이다. 고로, 게임에 사용되는 데이터 흐름에는 어울리지 않는다고 생각했다.
그리고 Pub/Sub 뿐 아니라 다양한 Json을 주고 받을 때 데이터의 저장이 필요한데, 이 경우에도 Redis가 굉장히 좋을 것이라 판단했다. (RedisJson 활용 등)
두 번째로는 Push Based Subscription이다. Kafka의 경우 subscriber가 pull요청을 날려야 Message를 받아오는 방식인 반면, Redis는 Publisher가 publish하면 자동으로 그 시점에 존재하는 Subscriber에게 메세지를 모두 날리는 방식이다. 고로 데이터를 동기화 시키는 중앙 서버에서는 Push Based Subscription이 어울린다고 생각했다.
이외에도 여러 차이점이 존재한다. 아래 StackOverflow에 간단하게 차이점을 정리해 둔 좋은 게시글이 존재한다.
StackOverflow - Redis vs Kafka
먼저, 나의 환경은 WebSocket + Stomp 환경이다.
// Redis
implementation('org.springframework.boot:spring-boot-starter-data-redis')
나는 gradle을 사용하였으므로 Build.gradle에 Redis를 추가해준다. maven은 maven repository에 가면 사용법이 나와있을 것이다.
import com.ssinhwa.gameserver.redisserver.controller.StompHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
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
// @EnableWebSocket
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
// 메시지 발행 요청 : /pub (Application Destination Prefix)
// 메시지 구독 요청 : /sub (enable Simple Broker)
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("/pub");
}
// Stomp WebSocket Endpoint : /ws-stomp
// Unity 에서 접속하려 하니 SockJS 를 빼야 했다.
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp")
.setAllowedOriginPatterns("*");
}
// StompHandler 가 WebSocket 앞단에서 Token 체크
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
/*
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
}
*/
}
configureMessageBroker
: 메세지 브로커에서 publish와 subscribe할 prefix 주소를 등록한다.registerStompEndpoints
: 웹소켓 엔드포인트 설정을 하는 곳이다. OriginPatterns도 설정이 가능하고, sockjs도 여기서 설정할 수 있다.configureClientInboundChannel
: 나는 WebSocket 연결 전에 Header에 있는 jwt 토큰이 유효한지 먼저 확인 후에 연결하기 때문에, ChannelInterceptor를 달아서 체크를 한다. (웹소켓 연결내에서는 인증이 불가능 한 것 같다)스프링 WebSocket 공식문서 여기에서 더 자세히 설정값을 확인할 수 있다.
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
// pub / sub 메세지를 처리하는 Listener
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory());
return container;
}
// 어플리케이션에서 사용할 redisTemplate 설정
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
return redisTemplate;
}
// 토큰 저장소로 활용할 redis template
@Bean
public RedisTemplate<?, ?> tokenRedisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
@Bean
ChannelTopic topic() {
return new ChannelTopic(REDIS_TOPIC);
}
}
RedisConnectionFactory
: Thread-Safe factory of Redis Connections. 사용할 수 있는 것들은 Jedis, Lettuce가 있는데, Lettuce를 사용한다. 왜냐하면 Lettuce는 Netty (비동기 이벤트 기반 고성능 네트워크 프레임워크) 기반의 Redis 클라이언트로 비동기로 요청을 처리하기 때문에 성능이 매우 좋기 때문이다.성능 비교 결과가 궁금하다면 Jedis vs Lettuce 를 확인해보자
RedisMessageListenerContainer
: pub/sub 메세지를 처리하는 Listener 설정으로, 위에서 설정한 connectionfactory를 세팅해주면 된다.RedisTemplate
: Redis Server에 Redis Command를 수행하기 위한 high-level-abstractions을 제공하는 템플릿이라고 한다. 또한 Object 직렬화와 Connection Management를 수행한다. 그래서 factory와 Key, Value 직렬화 방식을 설정해줘야 한다.나는 Key는 String, Value는 json으로 설정해주었다.나머지 코드는 추후 활용하려고 추가했는데, 리팩토링을 할 필요가 있을 것 같다.
@RequiredArgsConstructor
@Service
@Slf4j
public class RedisPublisher {
private final RedisTemplate<String, Object> redisTemplate;
public void publish(ChannelTopic topic, MessageDto message) {
log.info("Topic : " + topic.getTopic() + " Message : " + message.getMessage());
redisTemplate.convertAndSend(topic.getTopic(), message);
}
public void publish(ChannelTopic topic, String data) {
log.info("Topic : " + topic.getTopic());
log.info("Data : " + data);
redisTemplate.convertAndSend(topic.getTopic(), data);
}
}
publish
: publisher가 publish 함수를 실행하면 설정한 topic 경로로 그 뒤에 딸려오는 인자인 message나 data가 전송이 된다. 나의 경우 Chatting과 Game Data 두 가지를 처리하는 경우를 오버로딩을 이용하여 구현해놓았다.@RequiredArgsConstructor
@Service
@Slf4j
public class RedisSubscriber implements MessageListener {
private final ObjectMapper objectMapper;
private final RedisTemplate<String, Object> redisTemplate;
private final SimpMessageSendingOperations template;
@Override
public void onMessage(Message message, byte[] pattern) {
try {
// 발행된 데이터를 Deserialize
log.info("Redis Subscriber 호출");
String publishMessage = redisTemplate
.getStringSerializer().deserialize(message.getBody());
log.info("Publish Message : " + publishMessage);
MessageDto messageDto = objectMapper.readValue(publishMessage, MessageDto.class);
log.info("Redis Subscribe Message : " + messageDto.getMessage());
template.convertAndSend("/sub/chat/room/" + messageDto.getRoomId(), messageDto.getMessage());
} catch (JsonProcessingException e) {
log.error(e.getMessage());
}
}
}
@RequiredArgsConstructor
@Service
@Slf4j
public class RedisDataSubscriber implements MessageListener {
private final RedisTemplate<String, Object> redisTemplate;
private final SimpMessageSendingOperations template;
@Override
public void onMessage(Message message, byte[] pattern) {
// 발행된 데이터를 Deserialize
log.info("Redis Data Subscriber 호출");
log.info("Message : " + message.getBody());
template.convertAndSend("/sub/data", message.getBody());
}
}
이 경우에도 오버로딩으로 합치고 싶었는데, onMessage 함수가 분리가 안되는 것 같아서 subscriber를 따로 선언해주었다.
convertAndSend
메소드를 통해 해당 경로를 구독하고 있으면 뒤에 메세지를 받을 수 있게 된다.
이 Subscriber Class는 나중에 Controller에서 publish 하기 전에 추가해주게 되어있다.
@RestController
@RequiredArgsConstructor
@Slf4j
public class ChatController {
private final ChatServiceImpl chatService;
// private final KafkaTemplate<String, MessageDto> kafkaTemplate;
// private final KafkaProducer kafkaProducer;
private final ChatMessageHistoryRepository chatMessageHistoryRepository;
private final RedisPublisher redisPublisher;
private final RedisSubscriber redisSubscriber;
private final RedisMessageListenerContainer redisMessageListenerContainer;
private final TokenProvider tokenProvider;
@MessageMapping("/chat/message")
public void message(@RequestBody MessageDto message) {
String username = message.getWriter();
log.info("WebSocket Username : " + username);
log.info(message.getMessage());
chatMessageHistoryRepository.save(message);
redisMessageListenerContainer.addMessageListener(redisSubscriber, new ChannelTopic(RedisConstants.REDIS_TOPIC));
redisPublisher.publish(new ChannelTopic(RedisConstants.REDIS_TOPIC), message);
// kafkaProducer.send(KafkaConstants.KAFKA_TOPIC, message);
}
}
@MessageMapping
: 해당 경로로 소켓 메세지가 전송이 되면 수행이 된다는 의미의 어노테이션이다. 아까 WebSocketConfig에서 설정해준 setApplicationDestinationPrefix
뒤에 붙는다.이렇게하면 어느정도 설정이 끝났다. 다음에는 NestedJson을 어떻게 다루었고, 어떻게 Unity Client에 전송하게 해주었는지 정리해봐야겠다.