코딩하는 털보

21.10.22 TIL 본문

Diary/Today I Learned

21.10.22 TIL

이정인 2021. 11. 1. 16:32

Spring Data Redis

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

application.properties

spring.redis.host = localhost
spring.redis.port = 6379

Cacheable

Cache의 대상이 되는 정보들

  • 단순한, 또는 단순한 구조의 정보들을 반복적으로 동일하게 제공해야 하거나
  • 정보의 변경주기가 빈번하지 않고, 단위처리 시간이 오래걸리거나
  • 최신화가 반드시 실시간으로 이뤄지지 않아도 서비스 품질에 영향을 거의 주지 않는 정보인 경우

Cache를 사용할때 주의해야 할 것

  • 캐싱할 정보의 선택 -> 제일 중요하겠다.
  • 캐싱할 정보의 유효기간 (TTL - Time To Live ) 설정
  • 캐싱한 정보의 갱신시점

서비스를 설계할 때, 특히 백앤드의 경우 API 서비스의 기능설계단계에서 부터 Cache정책을 수립하는게 좋다
어떤 정보를 Cache로 적용할까를 먼저 따져보고 그 정보들을 어떤 시점에 어떤 주기로 갱신, 삭제를 할 지에 대한 최소한의 '캐싱전략'을 세우는 것도 어플리케이션 설계에서 중요한 영역중의 하나이다.

출처: https://yonguri.tistory.com/82 [대디장의 기억저장소]

Cacheable 사용하기

application.properties, 캐시 영역으로 redis를 사용하도록 설정

spring.cache.type=redis 
spring.redis.host=localhost 
spring.redis.port=6379

Application 클래스에 어노테이션 추가

@SpringBootApplication
@EnableCaching
public class ErrorPoolApplication {
}

@EnableCaching 어노테이션을 사용하면 Redis관련 Client가 자동으로 추가된다.
과거 버전의 스프링은 Jedis를 사용했지만 지금은 더 많은 장점을 가진 Lettuce를 채택하게 되었다.


com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling 

Timestamped의 LocalDateTime을 역직렬화하려고 할 때 발생하는 에러.
캐싱에서 사용할 ObjectMapper 설정에 모듈을 추가해주면 해결할 수 있다.

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // timestamp 형식 안따르도록 설정
        mapper.registerModules(new JavaTimeModule(), new Jdk8Module()); // LocalDateTime 매핑을 위해 모듈 활성화
        return mapper;
    }

완성된 Configuration 클래스

@Configuration
@EnableCaching
public class RedisCacheConfig {

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

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

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(new RedisStandaloneConfiguration(redisHost, redisPort));
    }

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        mapper.registerModules(new JavaTimeModule(), new Jdk8Module());
        return mapper;
    }

  //이 Jackson2JsonRedisSerializer 설정은 Jackson2JsonRedisSerializer가 어플리케이션의 커스텀 클래스로 역직렬화 하지 못하는 문제로 추가했다.
  //원인파악을 제대로 하려면 더 깊게 공부해야 한다.
  //cannot deserialize value of type XXXXX from array value
    private Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer() {
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer =
                new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = objectMapper();
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        return jackson2JsonRedisSerializer;
    }

    @Bean
    public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext
                        .SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext
                        .SerializationPair
                        //RedisCacheConfiguration에 위에서 설정한 Jackson2JsonRedisSerializer를 사용
                        .fromSerializer(jackson2JsonRedisSerializer()));

        Map<String, RedisCacheConfiguration> cacheConfiguration = new HashMap<>();
      //key prefix, 캐시 만료시간을 설정할 수 있다.
        cacheConfiguration.put("ARTICLE_LIST:", redisCacheConfiguration.entryTtl(Duration.ofSeconds(360L)));


        return RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }
}

주요 어노테이션

  • @Cacheable : 캐시에서 값을 가져온다. 만약 없으면 메서드 리턴을 캐시에 저장한다.
  • @CacehPut : 메서드 리턴을 캐시에 저장한다.
  • @CacheEvict : 캐시에서 값을 제거한다.

컨트롤러에서 Cacheable 어노테이션을 사용했다.
GET API의 요청에 있는 parameter, pathvariable 요소를 key 값으로 사용했다.
key값은 Spel로 작성하기 때문에 숫자 타입을 "#page+#skillId+#categoryId"와 같이 작성하면 덧셈으로 계산된 결과가 key가 된다.
제대로 캐시 데이터들을 구분하려면 요청에 맞게 key가 선택되어야 하므로 "#page.toString()+#skillId.toString()+#categoryId.toString()"로 변경하였다.

@ApiOperation(value = "항목 별 게시글 조회")
    @GetMapping("/articles/skill/{skill_id}/{category_id}")
    @Cacheable(value = "ARTICLE_LIST:", key = "#page.toString()+#skillId.toString()+#categoryId.toString()", cacheManager = "redisCacheManager")
    public DefaultResponse<ArticlePageResponseDto> getArticlesInSkillAndCategory(
            @ApiIgnore @AuthenticationPrincipal UserDetails userDetails,
            @ApiParam(value = "페이지 번호", required = true) @RequestParam("page") Integer page,
            @ApiParam(value = "주특기 번호", required = true) @PathVariable("skill_id") Integer skillId,
            @ApiParam(value = "카테고리 번호", required = true) @PathVariable("category_id") Integer categoryId,

처음 요청을 할 때는 데이터베이스에서 데이터를 요청하는 것을 확인할 수 있다.

Hibernate: select distinct article0_.id as id1_0_0_, likes1_.id as id1_2_1_, article0_.created_at as created_2_0_0_, article0_.modified_at as modified3_0_0_, article0_.category as category4_0_0_, article0_.content as content5_0_0_, article0_.img_url as img_url6_0_0_, article0_.skill as skill7_0_0_, article0_.title as title8_0_0_, article0_.user_id as user_id10_0_0_, article0_.view_count as view_cou9_0_0_, likes1_.article_id as article_2_2_1_, likes1_.user_id as user_id3_2_1_, likes1_.article_id as article_2_2_0__, likes1_.id as id1_2_0__ from article article0_ left outer join like_info likes1_ on article0_.id=likes1_.article_id where article0_.skill=? and article0_.category=? order by article0_.created_at desc
Hibernate: select count(distinct article0_.id) as col_0_0_ from article article0_ where article0_.skill=? and article0_.category=?
Hibernate: select comments0_.article_id as article_5_1_2_, comments0_.id as id1_1_2_, comments0_.id as id1_1_1_, comments0_.created_at as created_2_1_1_, comments0_.modified_at as modified3_1_1_, comments0_.article_id as article_5_1_1_, comments0_.content as content4_1_1_, comments0_.user_id as user_id6_1_1_, user1_.id as id1_3_0_, user1_.email as email2_3_0_, user1_.password as password3_3_0_, user1_.role as role4_3_0_, user1_.skill as skill5_3_0_, user1_.social_id as social_i6_3_0_, user1_.username as username7_3_0_ from comment comments0_ inner join user user1_ on comments0_.user_id=user1_.id where comments0_.article_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: select user0_.id as id1_3_0_, user0_.email as email2_3_0_, user0_.password as password3_3_0_, user0_.role as role4_3_0_, user0_.skill as skill5_3_0_, user0_.social_id as social_i6_3_0_, user0_.username as username7_3_0_ from user user0_ where user0_.id=?

redis-cli로 어떤 key들이 있는지 확인할 수 있다. 위에서 나온 결과가 제대로 저장되어 있는 것을 확인했다. (key = 1, 1, 1)

[/Users/ijeong-in]> redis-cli
127.0.0.1:6379> keys *
1) "ARTICLE_LIST:::111"

이후로 서버에 동일한 GET 요청을 하면 데이터베이스에 쿼리가 발생하지 않는 것을 확인할 수 있을것이다.

Comments