[IMAD] Spring Boot + Redis 사용 및 에러 핸들링

NCOOKIE·2023년 12월 31일
0

IMAD 프로젝트

목록 보기
9/11

Redis

개념

Redis는 속도가 빠른 인 메모리 키 값 데이터 구조 저장소 오픈 소스다. 다양한 인 메모리 데이터 구조를 제공하므로 다양한 사용자 정의 애플리케이션을 손쉽게 생성할 수 있다. 주요 Redis 사용 사례로는 캐싱, 세션 관리, 순위표 등이 있다. Redis는 현재 가장 인기 있는 key-value 저장소로서, BSD 라이선스가 있고 최적화된 C 코드로 작성되었으며, 다양한 개발 언어를 지원합니다. Redis는 REmote DIctionary Server의 약어입니다.

스프링 적용하기

build.gradle

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

의존성 추가

application.yml

  redis:
    host: 127.0.0.1
    port: 6379

Redis Config

@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 가 성능이 더 좋기에 디폴트로 세팅되어 있다.

RedisTemplate

Spring Data Redis에서 데이터에 접근하는 방법은 크게 두 가지가 있다.

  • CrudRepository (high level API)
  • RedisTemplate (low loevel API)

CrudRepository는 JPA처럼 사용 가능하다는 장점이 있다. 나는 같은 구조의 데이터를 이름만 다르게 사용할 것이기 때문에 RedisTemplate을 사용하고 있다.

RedisConfiguration.java

@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();
    }
}

https://loosie.tistory.com/807#2._RedisTemplate_(low_level)

ConnectionFactory는 lettuce로 설정한 다음 RedisTemplate 빈을 등록해준다.

Serializer

  • JdkSerializationRedisSerializer: 디폴트로 등록되어있는 Serializer이다.
  • StringRedisSerializer: String 값을 정상적으로 읽어서 저장한다. 그러나 엔티티나 VO같은 타입은 cast 할 수 없다.
  • Jackson2JsonRedisSerializer(classType.class): classType 값을 json 형태로 저장한다. 특정 클래스(classType)에게만 직속되어있다는 단점이 있다.
  • GenericJackson2JsonRedisSerializer: 모든 classType을 json 형태로 저장할 수 있는 범용적인
  • Jackson2JsonRedisSerializer이다. 캐싱에 클래스 타입도 저장된다는 단점이 있지만 RedisTemplate을 이용해 다양한 타입 객체를 캐싱할 때 사용하기에 좋다

Service

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_idContents entity를 다시 조회하는 번거로움을 덜기 위해 Contents 객체를 직렬화해서 저장했다.

에러

Java 8 date/time type java.time.LocalDate not supported by default

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.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 하지 못해 발생하는 문제인 것을 알 수 있다.

해결

build.gradle

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
implementation 'com.fasterxml.jackson.core:jackson-databind'

두 개 패키지의 의존성을 추가한다.

@JsonSerialize, @JsonDeserialize

@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

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

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에 저장되었었다.

참고

Redis

에러

profile
일단 해보자

0개의 댓글