스프링 Cache With Redis

허진혁·2023년 5월 30일
0

스프링에서 캐시

스프링은 캐시를 다음과 같이 정의해요.

Spring Framework provides support for transparently adding caching into an existing Spring application. Similar to the transaction support, the caching abstraction allows consistent use of various caching solutions with minimal impact on the code.

스프링은 트랜잭션과 유사하게 추상화하여 캐싱을 지원해줘요.

트랜잭션과 유사하게 AOP 방식으로 적용되어 개발자는 기술에 종속받지 않고 사용할 수 있어요.

캐시 추상화를 위해 CacheManager를 Bean으로 등록해야 하면 스프링이 알아서 적용해줘요.

스프링부트에서 캐시를 사용해보기

Redis 의존성 추가

Spring Boot offers basic auto-configuration for the Lettuce and Jedis client libraries and the abstractions on top of them provided by Spring Data Redis.

스프링 부트는 spring-boot-starter-data-redis 의존성을 추가하면 사용 가능해요.

You can inject an auto-configured RedisConnectionFactory, StringRedisTemplate, or vanilla RedisTemplate instance as you would any other Spring Bean. By default, the instance tries to connect to a Redis server at localhost:6379

레디스 서버의 포트번호는 디폴트가 6379이며, RedisConnectionFactory, StringRedisTemplate(or RedisTemplate)을 스프링 빈으로 등록해야 해요.

저는 jwt 토큰 인증을 위해 Redis를 사용하고 있었어요. 그래서 RedisTemplate은 다른 클래스에 정의되어 있어요.

RedisAuthConfig(토큰을 위한 Redis 서버)

(이 부분은 캐시와 크게 관련이 없지만 RedisTemplate 부분을 위해 추가한 것입니다.)

@Configuration
public class RedisAuthConfig {

    // ...
    
    @Bean(name = "redisTemplate")
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return redisTemplate;
    }
}

RedisCacheConfig

순서는 다음과 같아요.

  1. redis 연결 host와 port 정보를 주입해줘요.
  2. 캐싱 기능 연결을 위한 RedisConnectionFactory을 스프링 빈으로 등록해줘요.
  3. 캐시에서 redis 사용하기 위해 CacheManager을 스프링 빈으로 등록해줘요.
@Configuration
public class RedisCacheConfig {

    private final String host;
    private final int port;

    public RedisCacheConfig(
            @Value("${spring.data.redis.cache.host}") String host,
            @Value("${spring.data.redis.cache.port}") int port
    )
    {
        this.host = host;
        this.port = port;
    }


    @Bean
    public CacheManager cacheManager(@Qualifier("redisCacheConnectionFactory") RedisConnectionFactory connectionFactory) {
        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(connectionFactory)
                .cacheDefaults(defaultCacheConfiguration())
                .build();
    }

    private RedisCacheConfiguration defaultCacheConfiguration() {
        return RedisCacheConfiguration
                .defaultCacheConfig()
                .disableCachingNullValues()
                .entryTtl(Duration.ofDays(1L))
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
                );
    }

    @Bean(name = "redisCacheConnectionFactory")
    public RedisConnectionFactory redisCacheConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setPort(port);
        redisStandaloneConfiguration.setHostName(host);
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }
}

RedisCacheConfiguration을 스프링 빈으로 등록하기 위해 사용한 메서드를 정리해 볼게요.

  • defaultCacheConfiguration() : RedisCacheConfiguration 클래스는 설정 객체를 생성할 때 defaultCacheConfig()로 여러 조건들의 default를 설정할 수 있어요.
  • disableCachingNullValues() : cache이 value 값이 Null일 경우 에러를 던져줘요.
  • entryTtl() : TTL(TimeToLive)의 제한 시간을 설정해 두는 거에요.
  • computePrefixWith() : 캐시가 저장될 때 key값의 접두사를 정하는 거에요. 이는 다음과 같이 설명되어 있어요. 그래서 key 값이 "접두사(cacheName)::id" 이런 식으로 저장 되요. ex) "post::30182"

Creates a default CacheKeyPrefix scheme that prefixes cache keys with cacheName followed by double colons. A cache named myCache will prefix all cache keys with myCache::.

  • serializeKeysWith() : key값을 직렬화 시키는 방법을 정의해둘 거에요
  • serializeValuesWith() : value값을 직렬화 시키는 방법을 정의해둘 거에요.

위 2개의 메서드를 잠시 집중해볼게요. 우리는 RedisTemplate을 통해 Redis에 Map<String, Object>형태로 넣을거에요. 그래서 key값은 String, value는 Object 타입으로 고정이에요.

스프링은 웹에서 Json형태로 요청이 들어오면 HttpMessageConverter를 상속하는 MappingJackson2HttpMessageConverter를 통해 ObjectMapper가 JSON -> Object로 변환되요. -> (관련 내용)

이처럼 GenericJackson2JsonRedisSerializer은 객체의 클래스 지정 없이 모든 Class Type을 JSON 형태로 저장할 수 있는 Serializer에요. Class Type에 상관 없이 모든 객체를 직렬화해준다는 장점이 있어요.

❗️ 그러나 여기서 문제가 1개 생겨요.

> java.time.LocalDateTime not Supported

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type java.time.LocalDate not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: com.example.fasns.domain.member.dto.MemberDto["birth"])

위와 같이 LocalDatime 타입이 지원이 안되나봐요. 그렇치만 친절한 설명으로 jackson-datatype-jsr310 의존성을 추가하라 했지만, 이마저도 에러가 나왔어요.

그래서 ObjectMapper를 따로 설정해 주었어요.

@Configuration
public class RedisCacheConfig {
	// ...
    private RedisCacheConfiguration defaultCacheConfiguration() {
            return RedisCacheConfiguration
                    .defaultCacheConfig()
                    .disableCachingNullValues()
                    .entryTtl(Duration.ofDays(1L))
                    .computePrefixWith(CacheKeyPrefix.simple())
                    .serializeKeysWith(
                            RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                            // 아래 objectMapper 인자 추가
                    .serializeValuesWith(
                            RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper()))
                    );
        }

    private ObjectMapper objectMapper() {

        PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
                .allowIfBaseType(Object.class)
                .build();

        return new ObjectMapper()
                .findAndRegisterModules()
                .enable(SerializationFeature.INDENT_OUTPUT)
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                .registerModule(new JavaTimeModule())
                .activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
    }
}
  • registerModule() : 추가하고 싶은 모듈을 추가할 수 있어요. 저는 JavaTimeModule을 추가하여 LocalDateTime 역직렬화를 가능하도록 했어요.

  • activateDefaultTyping() : 다형성 타입의 적절한 역직렬화에 필요한 유형 정보("디폴트 타입")의 자동 포함을 활성화하는 방법이에요.(JsonTypeInfo로 유형에 주석을 달지 않은 경우)


힘든 설정이 드디어 끝났어요.

캐시 사용해보기

스프링 캐시 사용법은 다음 설명이 가장 깔끔해요

Declarative annotation-based caching

@Transactional 처럼 @Cacheable과 @CacheEvict 사용하면 되요.

@Cacheable

Cacheable의 파라미터중 중요 부분을 알아볼거에요.

  • value : 메서드의 반환 타입을 두는 곳이에요. 다만, cacheNames를 안쓰고 value만 쓴다면 Redis에 key는 "value::#id" 이렇게 저장되요.

  • CacheNames : Redis key에 저장할 때 접두사로 올 것이에요. 에를 들어 CacheNames="hi"로 정의해두면 "hi::#id" 이렇게 저장되요

  • key : key는 SpEL이 가능해요. 그래서 다음과 같이 사용할 수 있어요.

Spring Expression Language (SpEL) expression for computing the key dynamically

@Cacheable(value="books", key="#isbn"
public Book findBook(ISBN isbn)


@Cacheable(value="books", key="#isbn.rawNumber")
public Book findBook(ISBN isbn)


@Cacheable(value="books", key="T(someType).hash(#isbn)")
public Book findBook(ISBN isbn)
  • cacheManager : 위 RedisCacheConfig에서 설정한 cacheManager를 설정해두는 거에요. 스프링 빈으로 따로 이름을 정해두었으면 그 이름을 직접 호명해 사용하는 것이에요.

  • unless : 어떠한 조건이 일치하면 캐시에 넣지 않는 방식이에요. 예를 들어 unless "#id == 1"로 설정해두면 id가 1인 캐시는 저장하지 않는 것이죠.

  • condition : 위 unless와 반대로 조건에 맞아야만 캐시를 저장할 수 있어요. 예를들어 #id > 10000"으로 설정해두면 id가 10000 초과만 캐시에 저장하는 것이죠.

@CacheEvit

캐시에서 오래되거나 사용되지 않는 데이터를 제거하는 데 사용해요. @Cacheable과 반대로 @CacheEvict는 캐시 제거를 수행하는 메서드 즉, 캐시에서 데이터를 제거하는 트리거 역할을 하는 메서드를 정의하는 거에요. @CacheEvict는 작업의 영향을 받는 하나 또는 여러 개의 캐시를 지정해야 하며, 키 또는 조건을 지정할 수 있지만, 추가적으로 엔트리 하나(키를 기반으로)가 아닌 캐시 전체 제거를 수행해야 하는지 여부를 나타내는 추가 매개 변수 allEntries도 있어요.


드디어 긴 여정이 끝났어요.

마지막으로 1개 @EnableCaching만 추가해주면 되요.

@SpringBootApplication
@EnableCaching
public class FasnsApplication {

    public static void main(String[] args) {
        SpringApplication.run(FasnsApplication.class, args);
    }

}

응답 결과 차이

인텔리제이 http를 통해 API 응답속도 차이를 비교해 보았어요.

### Get Post 단건 조회;
GET http://localhost:8080/api/posts/30001
Content-Type: application/json

캐싱 전

캐싱 후

위 그림을 통해 캐싱이 있는지 체크해본 후에 같은 api를 날려 보았더니 아래와 같이 나왔어요. 항상 일정한 응답속도를 보장하지는 않지만, 응답속도 결과가 약 70%~90% 정도 증가한거 같아요.

스프링을 통해 캐싱을 적용해 보았어요. 특히 LocalDateTime 타입을 직렬화/역직렬화 하는 방식을 찾는 것에 많은 시간을 보냈지만, 그 시간만큼 많은 공부가 된 것 같아요.

참고자료

33. Caching
29. Cache Abstraction
Cache 기능을 Redis로 구현하기까지의 과정(2) - LocalDateTime 직렬화/역직렬화 방법

profile
Don't ever say it's over if I'm breathing

0개의 댓글