본문 바로가기

Redis

(3) 스프링 Redis 캐싱 및 세션 저장소와 분리

Redis는 서버에서 세션 저장소캐시 저장소로 많이 쓰인다.

한 개의 Redis 서버에서 둘을 모두 관리한다면 간편하지 않을까? 

그렇지 않다. 그럼 분리하는 이유를 알아보자.

 


 

1. Redis의 싱글 스레드 작업

Redis uses a mostly single threaded design. This means that a single process serves all the client requests, using a technique called multiplexing. This means that Redis can serve a single request in every given moment, so all the requests are served sequentially. This is very similar to how Node.js works as well. However, both products are not often perceived as being slow. This is caused in part by the small amount of time to complete a single request, but primarily because these products are designed to not block on system calls, such as reading data from or writing data to a socket. 
(https://redis.io/docs/management/optimization/latency/)
 

Diagnosing latency issues

Finding the causes of slow responses

redis.io

Redis는 싱글 스레드로 동작하여 데이터의 원자성(atomic)을 보장하여 동시성 문제가 발생하지 않지만, 모든 요청이 순차적으로 처리된다. 즉, 나중에 들어온 요청들은 앞의 요청들이 완료된 후에야 작업을 시작할 수 있다는 것이다.

1개의 Redis 서버와 2개의 Redis 서버

 

위 그림처럼 Redis를 분리하여 사용한다면 CPU 자원을 한 개 더 사용하여 세션 관련 요청과 캐시 관련 요청을 나눠서 처리한다면, 대기 시간이 줄어들고 처리 속도가 더 향상될 것이다.

즉, 각각의 기능별로 Redis 서버를 분리한다면 CPU 자원이 더 소모되지만 처리 속도는 더 높아질 것이다.

 

2. Swapping으로 인한 지연 시간

Redis의 빠른 처리 속도는 key-value 쌍으로 데이터를 저장하여 O(1)의 시간복잡도로 데이터를 가져오기 때문도 있지만, in-memory DB라는 이유도 크다.

한 개의 Redis 서버에 세션 정보와 캐시 데이터를 함께 저장한다면, 그 Redis는 각각을 따로 저장한 한 개의 Redis 서버보다 필요한 용량이 많아질 것이다. 그렇다면 paging 되어 있는 Redis page의 많은 부분이 swap 영역으로 이동되어 있을 것이고, 이로 인해 I/O 작업의 대기 시간 때문에 처리 속도가 떨어질 것이다. 

 

즉, Redis에 저장된 데이터가 많아져 swap이 발생한다면, Redis는 디스크를 읽게 되어 성능이 저하되고, 이것은 Redis의 큰 장점을 잃어버리게 되는 것이다.


If a Redis page is moved by the kernel from the memory to the swap file, when the data stored in this memory page is used by Redis (for example accessing a key stored into this memory page) the kernel will stop the Redis process in order to move the page back into the main memory. This is a slow operation involving random I/Os (compared to accessing a page that is already in memory) and will result into anomalous latency experienced by Redis clients.
https://redis.io/docs/management/optimization/latency/
 

Diagnosing latency issues

Finding the causes of slow responses

redis.io

 

3. 운영 및 유지보수

갑작스런 대규모 트래픽 등에 의하여 Redis에 장애가 생겼을 때, 각 저장소가 분리되어 있는 경우가 훨씬 대응하기 수월할 것이다. 

 

또한, 서버를 여러개 뒀을 때 세션의 정합성 문제 등을 해결하기 위해 Redis 서버를 따로 둘 수도 있을 것이다. 이 경우 한 개의 Redis 서버만 사용한다면 대규모 세션 관련 요청과 캐시 관련 요청에 의해 네트워크 병목 현상이 일어날 수도 있다.

그에 비하여 Redis 서버를 사용하는 목적에 따라 분리하여 운영한다면(ex. 각 다른 ec2 서버에서 실행), 네트워크 요청이 분산될 것이고 이는 대규모 트래픽에 좀 더 대응하기 수월할 것이다.

 


 

Redis Configuration

@Configuration
@EnableRedisHttpSession
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean(name = "sessionRedisConnectionFactory")
    @Primary
    public RedisConnectionFactory sessionRedisConnectionFactory() {
        return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
    }

    @Bean(name = "sessionRedisTemplate")
    public RedisTemplate<String, Object> sessionRedisTemplate(@Qualifier("sessionRedisConnectionFactory") RedisConnectionFactory cf) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(cf);

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

        Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        redisTemplate.setValueSerializer(jsonSerializer);
        redisTemplate.setHashValueSerializer(jsonSerializer);

        return redisTemplate;
    }


}
@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Value("${spring.cache.redis.host}")
    private String cacheHost;

    @Value("${spring.cache.redis.port}")
    private int cachePort;

    @Bean(name = "cacheRedisConnectionFactory")
    public RedisConnectionFactory cacheRedisConnectionFactory() {
        return new LettuceConnectionFactory(new RedisStandaloneConfiguration(cacheHost, cachePort));
    }

    public ObjectMapper redisObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.activateDefaultTyping(BasicPolymorphicTypeValidator
                        .builder()
                        .allowIfSubType(Object.class)
                        .build()
                , ObjectMapper.DefaultTyping.EVERYTHING);
        objectMapper.registerModules(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS);

        return objectMapper;
    }
    @Bean(name = "cacheManager")
    public RedisCacheManager redisCacheManager(
            @Qualifier("cacheRedisConnectionFactory") RedisConnectionFactory redisConnectionFactory) {

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
                .defaultCacheConfig()
                .disableCachingNullValues()
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper())));

        return RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }

}

 

한 개의 Redis만 사용할 때는 RedisConnectionFactory Bean에 name을 설정해주지 않아도 된다.

하지만 Redis 2개를 사용한다면, 이는 name을 설정하여 Bean의 충돌을 없애야 한다.

 

또한 RedisTemplateRedisCacheManager는 RedisConnectionFactory가 필요한데, 이 때 각각의 RedisConnectionFactory를 구분하기 위하여 이전에 설정한 Bean의 name을 @Qualify로 구분해준다.

 

CacheManager에서 ObjectMapper를 사용하는 이유는 LocalDateTime을 Redis에 저장하고 조회할 때, 직렬화 문제를 해결하기 위해 사용한다. 

 

@Cacheable

    @Transactional(readOnly = false)
    @Cacheable(value = "board", key = "#boardId", condition = "#boardId != null", cacheManager = "cacheManager")
    public GetBoardResponseDto getBoard(Long boardId) {

		// ... 게시글 조회
        
        return GetBoardResponseDto.of(...);
    }

 

@Cacheable은 캐시 가능한 메서드를 구분하는데 사용한다. 

결과가 캐시에 저장되어 이후의 코드를 실행하지 않고도 값이 반환되도록 한다.

  • value(=cacheName) : 캐시의 이름을 지정한다.
  • key : 캐시 키를 지정한다.
  • unless : 결과가 true가 아닐 경우에만 캐싱된다. and, or 표현식으로 여러 개의 조건이 사용 가능하다.
  • condition : 결과가 true인 경우에만 캐싱된다. and, or 표현식으로 여러 개의 조건이 사용 가능하다.
  • cacheManager : 사용할 CacheManager를 지정한다.
  • sync : 다중 스레드에서 호출될 때, 캐시를 동기화할지 여부를 결정. default = false이며 비동기로 동작한다.
  • keyGenerator : cache key를 만들고자 할 때 사용하며, 키 생성기를 지정한다.

캐시 결과

인덱스가 1인 게시글을 조회했을 때, 캐싱되어 위 형태의 key로 저장된다.

 

@CacheEvict

    @CacheEvict(value = "board", key = "#boardId")
    public void deleteBoard(User user, Long boardId) {
		
        // ... 게시글 삭제
    }

 

@CacheEvict는 캐시에서 데이터를 삭제하기 위한 메서드를 구분하는데 사용한다.

  • value(=cacheName) : 캐시의 이름을 지정한다.
  • key : 캐시 키를 지정한다.
  • unless : 결과가 true가 아닐 경우에만 캐싱된다. and, or 표현식으로 여러 개의 조건이 사용 가능하다.
  • condition : 결과가 true인 경우에만 캐싱된다. and, or 표현식으로 여러 개의 조건이 사용 가능하다.
  • cacheManager : 사용할 CacheManager를 지정한다.
  • keyGenerator : cache key를 만들고자 할 때 사용하며, 키 생성기를 지정한다.
  • allEntries : Cache Key에 대한 전체 데이터의 삭제 여부를 지정한다. true일 경우 모두 삭제 된다.

인덱스가 1인 게시글을 삭제했을 때는, 앞으로 조회되면 안되기 때문에 캐시에서도 같이 삭제해줘야 한다.

 

@CachePut

@CachePut(value="board", key="#boardId", cacheManager="cacheManager")
public void modifyBoard(Long boardId, RequestDto dto) {
	//.. 게시글 수정
}

 

@CachePut은 캐시에 저장된 데이터를 수정하기 위해 사용된다.

  • value(=cacheName) : 캐시의 이름을 지정한다.
  • key : 캐시 키를 지정한다.
  • unless : 결과가 true가 아닐 경우에만 캐싱된다. and, or 표현식으로 여러 개의 조건이 사용 가능하다.
  • condition : 결과가 true인 경우에만 캐싱된다. and, or 표현식으로 여러 개의 조건이 사용 가능하다.
  • cacheManager : 사용할 CacheManager를 지정한다.
  • keyGenerator : cache key를 만들고자 할 때 사용하며, 키 생성기를 지정한다.

게시글을 수정할 때 그 게시글이 캐싱되어 있으면, 수정되기 전의 데이터를 반환하므로 캐시의 데이터도 수정해줘야 한다.

 

@Caching

    @Transactional(readOnly = false)
    @Caching(evict = {
            @CacheEvict(value = "boardList", allEntries = true),
            @CacheEvict(value = "keyword", allEntries = true)
    })
    public void func(RequestDto dto) {
		//...

    }

 

@Caching은 @Cacheable, @CacheEvict 또는 @CachePut을 여러 개 지정해야 하는 경우에 사용한다.

 

예를 들어, 어떤 데이터가 여러 개의 Key로 캐싱되어 있을 경우엔 해당 캐시들을 모두 삭제 또는 수정해야한다.

 


 

주의할 점

Redis는 간단한 get/set의 경우 CPU 속도에 영향을 받긴 하지만, 초당 10만 TPS 이상이 가능하다고 한다.

Redis의 속도가 빠른 이유 중 하나는 낮은 시간복잡도이다. 하지만 flushall, keys 등과 같은 O(N)의 시간복잡도를 가지는 작업은 싱글 스레드로 작업하는 Redis의 성능을 많이 떨어트릴 것이다.

그러므로 O(N)의 시간 복잡도를 가지는 작업은 최대한 지양해야 할 것이다.

 

마무리

Redis의 특징과 Redis를 목적에 따라 분리하면서 얻는 이점들에 대해서 공부할 수 있었다.

 

캐시를 적용하여 조회 성능을 크게 개선시키고, 캐싱되어 있는 데이터는 DB를 조회하지 않기 때문에 DB의 부하 또한 감소시킬 수 있다.

또한 각 저장소를 목적에 따라 분리함으로써 CPU 자원을 더 사용하게 되고, 각 Redis 서버에 장애가 발생해도 좀 더 수월하게 대응하여 서비스의 안정성도 높아질 것이다.

 

'Redis' 카테고리의 다른 글

(2) 스프링 직렬화 For RedisCache, RedisSerializer  (0) 2024.02.29
(1) Redis란 ?  (1) 2024.02.28