Spring boot Redis feat MSA

Jongmyung Choi·2023년 8월 27일
1

개요

우리 서비스에서 redis를 도입한 이유와 그 과정에 대해 기록하고자 글을 작성하였다.
Redis를 사용하는 목적은 굉장히 많다.
In-memoryDB 사용으로 높은 성능, 다양한 데이터 구조, 캐싱, 메시지 브로커, 세션 저장소, 대기열 관리, 실시간 데이터 분석, 분산 환경 지원 등..

우리는 refreshToken 관리,게임방 관리 에 Redis를 적용했다.

RefreshToken 관리

RefreshToken을 저장하는데에 Redis를 사용한 가장 큰 이유는 TTL(Time-To-Live) 기능 때문이다.

데이터의 만료 시간을 설정할 수 있고 이를 통해 서버에 저장할 Refresh Token의 만료 시간을 쉽게 설정할 수 있게 된다. 만료된 Refresh Token은 Redis에서 자동으로 삭제된다.

또한 추후에 글로 작성할 Redis 블랙리스트를 통한 특정 토큰을 무력화 하기 위해서도 Redis가 필요했다.

redisTemplate.opsForValue().set(id,refreshToken,14,TimeUnit.Days);

이렇게 코드를 작성하게 되면 user의 id를 키값으로, refreshToken을 value 값으로 저장하게 된다.

게임 방 관리

우리는 진행하는 대기방, 게임방을 관리하기 위해서 Redis를 사용했다.
사용 이유에 대해 설명하기전에 먼저 우리 서버의 구조를 보면

서버가 Auth, Business, Game 으로 나뉘어져 있다.
Game Server에서는 인게임 내에서의 게임 동작, 진행 과 관련된 로직을 수행하고 있고 Business Server에서는 대기실, 로비에서의 모든 작업을 수행한다.

이러한 아키텍처 구조를 가지고 있는 서비스에서 Redis를 사용한 이유는 다음과 같다.

1. 서버 부하

서버에서 자체적으로 대기방 또는 게임방에 대한 정보를 가지고 있다면 방이 많아질수록 서버에 부하가 커질 수 있기때문에 외부 저장소에 저장하였다.
외부 저장소중 In-memory DB인 Redis를 선택한 이유는 3번에 나와있다.

2. 데이터 동기화

목적에 따라 서버를 분리하였기에 서버간의 데이터 동기화를 위해 사용하였다. 예를들어 비즈니스 서버에서 저장한 대기방의 정보를 게임 서버에서 게임을 시작할 때 사용하게 된다.
또한 오토스케일링 시 데이터 동기화를 통해 일관성을 유지하기 위해 사용하였다.

3. 빠른 데이터 접근

대기방, 게임방은 데이터의 변동이 매우 자주 일어난다.
그렇기 때문에 In-memoryDB의 특성인 빠른 데이터 접근속도를 이용하여 빠르게 입력 또는 조회를 할 수 있게 하였다.

4. 데이터의 지속성

레디스에 데이터를 저장하여 특정 인스턴스가 삭제되거나 추가되어도 데이터가 유실되지 않도록 하였다.

RedisTemplate의 직렬화

RedisTemplate의 직렬화 때문에 문제가 많았는데 우선 직렬화를 살펴보자.
스프링에서 Redis 직렬화 구현체는 여러개가 존재한다.
대표적인것만 살펴보자

JdkSerializationRedisSerializer

Default 구현체 이다.
아무것도 설정을 안하면 이 구현체로 설정이 되는데 기본 자바 직렬화 방식을 사용한다. 따라서 객체에 @Serializable만 붙이면 간편하게 사용가능하다.
하지만 단점이 치명적이라 사용하지 않았다.

  1. 직렬화된 데이터의 크기가 크고 직렬화/역직렬화 프로세스가 느리다.
    -> 기본적으로 타입에 대한 정보 등 클래스 메타 정보들을 가지고 있기 때문에 다른 포맷에 비해 용량이 크다
  2. 클래스의 기본 해쉬값을 serialVersionUID로 사용하기 때문에 클래스 구조가 조금이라도 바뀌면 역직렬화시 오류가 발생한다.

GenericJackson2JsonRedisSerializer

Jackson 라이브러리를 통하여 직렬화를 하며 Class Type을 지정할 필요없이 다양한 유형의 Java 객체를 직렬화 및 역직렬화할 수 있다.
이것 또한 치명적인 단점이 있다.
Class의 패키지까지 저장하므로 MSA 환경에서 사용하려면 다른 서버의 루트, 이름 까지 전부 일치해야 된다는 단점이 있다.

Jackson2JsonRedisSerializer

이거또한 Jackson 라이브러리를 통하여 Json으로 직렬화 해주는데 Class 필드를 따로 저장하지 않는다.
하지만 Serializer에 해당 클래스 타입을 함께 지정해줘야해서 한개의 클래스만 직렬화가 가능하다는 단점이 있다.

StringRedisSerializer

String 값을 그대로 저장한다.
따라서 객체를 저장할 때, 꺼내올때 Json형태로 인코딩 디코딩 해줘야 한다.
이외에는 딱히 단점이 없고 위의 Serializer들의 단점을 커버하므로 적합하다.

RedisRepository의 직렬화

RedisTemplate을 사용하면 좀더 유연하고 다양한 Redis작업을 수행할 수 있지만 그냥 간단한 CRUD만 할거라면 RedisRepository도 괜찮다.
RedisRepository는 JPA 처럼 인터페이스로 쉽게 사용할 수 있다.
단점으로는 CRUD 같은 간단한 작업만 수행할 수 있고 복잡한 쿼리를 정의하거나 직접 Redis에 명령을 내릴때는 제한적이다.
또 특정 클래스를 지정해야 한다는 단점이 있다.

발생한 문제

MSA 환경의 분산서버에서 Redis에 객체를 저장할 때 직렬화 문제가 생겼다.
비즈니스 서버에서 대기방 정보를 Redis에 저장하고 게임이 시작되면
게임 서버에서 Redis에 있는 대기방 정보를 꺼내와 게임방을 생성하는데
이 과정에서 직렬화 에러가 났다.

해결 1. Jackson2JsonRedisSerializer

Jackson2JsonRedisSerializer를 이용해서 게임방을 위한 RedisTemplate, 대기방을 위한 RedisTemplate 두개를 만들었다.

@Bean
  public RedisTemplate<?, ?> gameRoomRedisTemplate() {

    RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory());
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashKeySerializer(new StringRedisSerializer());

    redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(GameInfo.class));
    redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer(GameInfo.class));
    return redisTemplate;
  }
  @Bean
  public RedisTemplate<?, ?> waitRoomRedisTemplate() {

    RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory());
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashKeySerializer(new StringRedisSerializer());

    redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(WaitRoom.class));
    redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer(WaitRoom.class));
    return redisTemplate;
  }
@Qualifier("gameRoomRedisTemplate")
  private RedisTemplate gameRoomRedisTemplate;
@Qualifier("waitRoomRedisTemplate")
  private RedisTemplate waitRoomRedisTemplate;

그리고 저장하거나 꺼낼때 다음과 같이 선언하고 사용하면 된다.
이렇게 하면 MSA 환경에서 직렬화시 문제가 없었지만 만약 클래스가 많아진다면 좀 귀찮아 질 것 같다.

해결2. StringRedisSerializer

StringRedisSerializer 를 이용해서 직렬화 하였다.

@Bean
public RedisTemplate<?, ?> redisTemplate() {
    RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory());
    redisTemplate.setEnableTransactionSupport(true);

    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new StringRedisSerializer());

    redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashValueSerializer(new StringRedisSerializer());
    return redisTemplate;
}

또는

@Bean
  public StringRedisTemplate stringRedisTemplate(){
    StringRedisTemplate stringRedisTemplate=new StringRedisTemplate();
    stringRedisTemplate.setKeySerializer(new StringRedisSerializer());
    stringRedisTemplate.setValueSerializer(new StringRedisSerializer());
    stringRedisTemplate.setConnectionFactory(redisConnectionFactory());
    return stringRedisTemplate;
  }

둘의 차이는 직렬화 방식이다. RedisTemplateDefaultJdkSerializationRedisSerializer 이고 StringRedisTemplateStringRedisSerializer를 사용한다.

이렇게 StringRedisSerializer 를 사용하여 객체를 저장하고 가져올때는 추가적으로 해줘야 할 부분이 있다. 객체를 JSON 형태로 변환시키거나 JSON을 객체로 변환시켜줘야 한다.
JSON 변환을 위해 ObjectMapper 를 사용하였다.

public <T> boolean saveData(String key, T data) {
	try {
      ObjectMapper mapper = new ObjectMapper();
      String value = objectMapper.writeValueAsString(data);
      redisTemplate.opsForValue().set(key, value);
      return true;
    } catch(Exception e){
    	log.error(e);
      	return false;
    }
}

public <T> Optional<T> getData(String key, Class<T> classType) {
	String value = redisTemplate.opsForValue().get(key);
    
    if(value == null){
    	return Optional.empty();
    }
    
	try {
    	ObjectMapper mapper = new ObjectMapper();
     	return Optional.of(objectMapper.readValue(value));
    } catch(Exception e){
    	log.error(e);
      	return Optional.empty();
    }
}

이렇게 메소드를 만들어놓고 사용하였다.

해결 3. RedisRepository

RedisRepository 를 사용하여 저장해봤다.
RedisRepository 사용을 위하여 엔티티에 `

@RedisHash("gameRoom")
public class GameRoom {
	@Id
	String id;
}
@RedisHash("waitRoom")
public class WaitRoom {
	@Id
	String id;
}

@RedisHash 어노테이션과 Id 값을 넣어줘야 되고
이는 Redis에 저장될때 key값이 gameRoom:{id} 형태로 저장되게 된다.

또한 RedisConfig에 @EnableRedisRepositories 어노테이션을 붙여준다.

public interface GameRoomRedisRepository extends CrudRepository<GameRoom, String> {
}
public interface WaitRoomRedisRepository extends CrudRepository<WaitRoom, String> {
}

와 같이 Repository를 만들고

사용할때는

private final GameRoomRedisRepository redisRepository;

선언 후 JPA Repository처럼 CRUD 메소드를 사용하면된다.

profile
총명한 개발자

0개의 댓글