Redis는 속도가 빠른 인 메모리 키 값 데이터 구조 저장소 오픈 소스다. 다양한 인 메모리 데이터 구조를 제공하므로 다양한 사용자 정의 애플리케이션을 손쉽게 생성할 수 있다. 주요 Redis 사용 사례로는 캐싱, 세션 관리, 순위표 등이 있다. Redis는 현재 가장 인기 있는 key-value 저장소로서, BSD 라이선스가 있고 최적화된 C 코드로 작성되었으며, 다양한 개발 언어를 지원합니다. Redis는 REmote DIctionary Server의 약어입니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
의존성 추가
redis:
host: 127.0.0.1
port: 6379
@EnableCaching
@EnableJpaAuditing
@SpringBootApplication
public class ImadServerApplication {
public static void main(String[] args) {
SpringApplication.run(ImadServerApplication.class, args);
}
}
스프링 앱이 캐싱 기능을 사용할 수 있게 @EnablaeCaching 애노테이션을 추가해준다. spring boot 2.0 이상부터는 jedis 가 아닌 lettuce를 이용해서 redis 에 접속하는게 디폴트이다. jedis, lettuce 모두 redis 접속 connection pool 관리 라이브러리이며, lettuce 가 성능이 더 좋기에 디폴트로 세팅되어 있다.
Spring Data Redis에서 데이터에 접근하는 방법은 크게 두 가지가 있다.
CrudRepository는 JPA처럼 사용 가능하다는 장점이 있다. 나는 같은 구조의 데이터를 이름만 다르게 사용할 것이기 때문에 RedisTemplate을 사용하고 있다.
@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
@EnableConfigurationProperties({ RedisProperties.class })
public class RedisConfiguration {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort());
configuration.setPassword(redisProperties.getPassword());
return new LettuceConnectionFactory(configuration);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new Jackson2JsonRedisSerializer<>(Contents.class));
redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Integer.class));
return redisTemplate;
}
// 레디스 캐시
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext
.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext
.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
ConnectionFactory는 lettuce로 설정한 다음 RedisTemplate 빈을 등록해준다.
Serializer
private final RedisTemplate<String, Object> redisTemplate;
@Description("매일 자정마다 Redis에 작품 랭킹 점수 저장")
@Scheduled(cron = "0 0 0 * * ?") // 자정마다 실행
public void saveContentsDailyRankingScore() {
// 현재 날짜를 가져옴
LocalDate currentDate = LocalDate.now();
// `20231231` 과 같은 형식으로 변환
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
String key = currentDate.format(formatter);
// 만약 같은 키의 데이터가 이미 존재한다면 삭제
redisTemplate.delete(key);
// String 형태의 key를 가지고, Contents 데이터를 value로 가짐
ZSetOperations<String, Object> dailyScoreSet = redisTemplate.opsForZSet();
// MySQL DB에 있는 당일 랭킹 점수를 Redis에 저장함
List<ContentsDailyScore> dailyScoreList = contentsDailyScoreRepository.findAll();
for (ContentsDailyScore dailyScore : dailyScoreList) {
Hibernate.initialize(dailyScore.getContents());
dailyScoreSet.add(key, dailyScore.getContents(), dailyScore.getRankingScore());
log.info(String.format("[%s][%s] 작품 랭킹 점수 저장 완료", key, dailyScore.getContents().getTranslatedTitle()));
}
}
자료구조의 key는 당일 날짜를 형식에 맞게 변환하여 사용하고, sorted set의 key 역할을 하는 값을 Contnets
클래스로 설정해줬다. 나중에 데이터를 꺼내와서 contents_id
로 Contents
entity를 다시 조회하는 번거로움을 덜기 위해 Contents
객체를 직렬화해서 저장했다.
java.time.LocalDate
not supported by defaultcom.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.ncookie.imad.domain.contents.entity.TvProgramData["firstAirDate"])
at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1306) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.ser.impl.UnsupportedTypeSerializer.serialize(UnsupportedTypeSerializer.java:35) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:733) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:774) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeWithType(BeanSerializerBase.java:657) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.ser.impl.TypeWrappedSerializer.serialize(TypeWrappedSerializer.java:32) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider._serialize(DefaultSerializerProvider.java:480) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:319) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.ObjectMapper._writeValueAndClose(ObjectMapper.java:4624) ~[jackson-databind-2.14.2.jar:2.14.2]
at com.fasterxml.jackson.databind.ObjectMapper.writeValueAsBytes(ObjectMapper.java:3892) ~[jackson-databind-2.14.2.jar:2.14.2]
at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.serialize(GenericJackson2JsonRedisSerializer.java:208) ~[spring-data-redis-3.0.5.jar:3.0.5]
at org.springframework.data.redis.core.AbstractOperations.rawValue(AbstractOperations.java:128) ~[spring-data-redis-3.0.5.jar:3.0.5]
at org.springframework.data.redis.core.DefaultZSetOperations.add(DefaultZSetOperations.java:56) ~[spring-data-redis-3.0.5.jar:3.0.5]
at com.ncookie.imad.domain.ranking.service.ContentsRankingScoreUpdateService.saveContentsDailyRankingScore(ContentsRankingScoreUpdateService.java:121) ~[classes/:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
Contents
Entity 객체를 Redis에 저장하려고 했는데 에러가 발생했다.
로그를 읽어보면 LocalDate 자료형의 firstAirDate
필드를 serialize 하지 못해 발생하는 문제인 것을 알 수 있다.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
implementation 'com.fasterxml.jackson.core:jackson-databind'
두 개 패키지의 의존성을 추가한다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@CreatedDate
private LocalDateTime createdDate;
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@LastModifiedDate
private LocalDateTime modifiedDate;
}
@DiscriminatorValue("tv")
@Entity
public class TvProgramData extends Contents {
@JsonSerialize(using = LocalDateSerializer.class)
@JsonDeserialize(using = LocalDateDeserializer.class)
private LocalDate firstAirDate;
@JsonSerialize(using = LocalDateSerializer.class)
@JsonDeserialize(using = LocalDateDeserializer.class)
private LocalDate lastAirDate;
private Integer numberOfEpisodes;
private Integer numberOfSeasons;
}
LocalDate, LocalDateTime 등 필드의 serialize 방식을 기입하는 어노테이션을 추가한다.
org.springframework.data.redis.serializer.SerializationException: Could not write JSON: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: com.ncookie.imad.domain.contents.entity.Contents$HibernateProxy$ZceJZbbl["hibernateLazyInitializer"])
at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.serialize(GenericJackson2JsonRedisSerializer.java:210) ~[spring-data-redis-3.0.5.jar:3.0.5]
at org.springframework.data.redis.core.AbstractOperations.rawValue(AbstractOperations.java:128) ~[spring-data-redis-3.0.5.jar:3.0.5]
at org.springframework.data.redis.core.DefaultZSetOperations.add(DefaultZSetOperations.java:56) ~[spring-data-redis-3.0.5.jar:3.0.5]
at com.ncookie.imad.domain.ranking.service.ContentsRankingScoreUpdateService.saveContentsDailyRankingScore(ContentsRankingScoreUpdateService.java:125) ~[classes/:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
@Entity
public class ContentsDailyScore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "contents_id")
@ToString.Exclude
private Contents contents;
@Setter
private int rankingScore;
}
Contents
객체를 serialize 하려는데 대상이 프록시 객체이기 때문에 발생하는 오류였다. 성능 상의 이유로 대부분의 DB 연관관계에서 LAZY 전략을 채택하고 있었기 때문에 프록시 객체가 반환되고 있었다.
@Entity
public class ContentsDailyScore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "contents_id")
@ToString.Exclude
private Contents contents;
@Setter
private int rankingScore;
}
ContentsDailyScore
클래스의 Contetns
필드만 EAGER 전략으로 변경하면서 해결할 수 있었다.
org.springframework.data.redis.serializer.SerializationException: Cannot serialize
at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:96) ~[spring-data-redis-3.0.5.jar:3.0.5]
at org.springframework.data.redis.core.AbstractOperations.rawHashKey(AbstractOperations.java:167) ~[spring-data-redis-3.0.5.jar:3.0.5]
at org.springframework.data.redis.core.DefaultHashOperations.put(DefaultHashOperations.java:194) ~[spring-data-redis-3.0.5.jar:3.0.5]
at com.ncookie.imad.domain.ranking.service.ContentsRankingScoreUpdateService.saveContentsDailyRankingScore(ContentsRankingScoreUpdateService.java:126) ~[classes/:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
...
Caused by: java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [com.ncookie.imad.domain.contents.entity.MovieData]
at org.springframework.core.serializer.DefaultSerializer.serialize(DefaultSerializer.java:43) ~[spring-core-6.0.8.jar:6.0.8]
at org.springframework.core.serializer.Serializer.serializeToByteArray(Serializer.java:56) ~[spring-core-6.0.8.jar:6.0.8]
at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:60) ~[spring-core-6.0.8.jar:6.0.8]
... 34 common frames omitted
redisTemplate.setHashKeySerializer(new Jackson2JsonRedisSerializer<>(Contents.class));
redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Integer.class));
// String 형태의 key를 가지고, Contents 데이터를 value로 가짐
HashOperations<String, Object, Object> dailyRankingScoreHash = redisTemplate.opsForHash();
// MySQL DB에 있는 당일 랭킹 점수를 Redis에 저장함
List<ContentsDailyScore> dailyScoreList = contentsDailyScoreRepository.findAll();
for (ContentsDailyScore dailyScore : dailyScoreList) {
dailyRankingScoreHash.put(key, dailyScore.getContents(), dailyScore.getRankingScore());
log.info(String.format("[%s][%s] 작품 랭킹 점수 저장 완료", key, dailyScore.getContents().getTranslatedTitle()));
}
Redis에서 사용하는 자료구조를 Sorted Set이 아니라 Hash를 사용하려고 하니 에러가 발생했다.
RedisTemplate에서 Hash 자료구조를 사용할 때 hash key와 hash value의 serializer를 설정할 수 있는데, 이걸 하지 않고 있었다.
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new Jackson2JsonRedisSerializer<>(Contents.class));
redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Integer.class));
return redisTemplate;
}
setHashKeySerializer(...)
, setHashValueSerializer(...)
메소드들을 설정하고 나서 정상동작했다. hash value도 설정해주지 않으면 숫자 데이터가 깨져서 DB에 저장되었었다.