Backend/프로젝트

우당당탕 눈물의 SpringBoot Redis Cache 적용기

동구름이 2024. 5. 12. 20:35

 프로젝트에 레디스 Cache를 적용하며 많은 에러를 경험했습니다. 그리고 에러를 해결하며, 정확히 이해하지 못했던 개념들을 다시 되짚어볼 수 있었습니다.

 

 레디스 Cache를 적용하며 겪은 에러는 크게 4가지인데, 하나씩 살펴보며 해결책과 관련 개념을 정리했습니다.

 

 

 우선, 요구사항은 다음과 같았습니다.

 

 서비스에서 이루어지는 메서드 들 중 findUsersById 를 통해 사용자 인증 과정을 거치게 했는데, 아래의 findUsersById 메서드에서 DB에 접근하는 횟수를 줄이는 것이 목표였습니다.

public UserEntity findUsersById(Long userId){
     return userRepository.findById(userId)
            .orElseThrow(() -> new NoSuchElementException("사용자를 찾을 수 없습니다. userId: "+userId));
}

 

 

 

 

1. 같은 클래스 내의 메서드에서는 캐시 적용이 되지 않는다! (self-invocation 문제)

문제 상황

 분명 레디스 설정을 올바르게 하고, @EnableCache, @Cacheable와 같은 어노테이션도 적용을 올바르게 했습니다. 그리고 스프링부트와 연결이 된 것도 확인했는데, 레디스에 값이 들어가지 않는 문제가 있었습니다.

 

 구체적인 상황은 아래와 같았습니다.

@Service
@RequiredArgsConstructor
public class UserService {
	
	...

    @Transactional(readOnly = true)
    public String getUserNickname(Long userId) {

        UserEntity user = userService.findUsersById(userId);
        String nickname = user.getNickname();
        return nickname;
    }
    
	...
    
    
    @Cacheable(key = "#userId", value="users")
    public UserEntity findUsersById(Long userId){
        return userRepository.findById(userId)
                .orElseThrow(() -> new NoSuchElementException("사용자를 찾을 수 없습니다. userId: "+userId));
    }
}

 

 위의 getUserNickname은 사용자의 nickname을 반환합니다. 그리고 내부에서 findUserById 메서드를 통해 user를 조회하게 했습니다. 그리고 이것을 테스트해보았더니 캐시가 전혀 동작을 하지 않았습니다.

 

 그러다 아래처럼 다른 클래스의 메서드에서 캐시가 적용된 메서드를 호출해보았더니 정상적으로 캐시가 동작을 했습니다.

//BoardService.java

@Transactional
public int save(BoardRequest request, Long userId) {
    UserEntity user = userService.findUsersById(userId);
    ...
    BoardEntity board = boardRepository.save(request.toEntity(user));
    return board.getId();
}

 

 

문제 원인

이것은 스프링의 캐시 매커니즘을 이해하지 못해 발생한 문제였습니다. 동일 클래스 내에서 캐시가 적용된 메서드를 호출할 때, 프록시 기반 AOP 방식이 동작하지 않기 때문입니다.

 

 

 Spring의 캐싱(@Cacheable) 어노테이션은 프록시 패턴을 통해 메서드 호출을 가로채고, 해당 메서드의 결과를 캐시하거나 캐시된 결과를 반환하는 방식입니다.

 여기서 프록시 패턴이란, 실제 객체를 대리하는 프록시 객체를 사용하는 것을 말합니다. 클라이언트가 프록시를 통해 실제 객체의 메서드를 호출하면 프록시는 이를 가로채고 추가적인 기능(로깅, 캐싱, 접근 제어 등)을 수행한 후 실제 객체의 메서드를 호출하게 됩니다.

 

 

 

구체적인 캐싱 동작을 살펴보면,

 

1.  Spring이 @Cacheable 어노테이션이 적용된 메서드를 가진 빈(Bean)을 초기화할 때, 실제 객체의 대리 역할을 하는 프록시 객체를 생성한다.

 

2. 클라이언트가 프록시 객체의 메서드를 호출하면, 프록시가 실제 메서드 호출을 가로채고 캐시 관련 로직을 수행한다.

 이 때, 프록시는 캐시된 결과가 있는지 확인하고, 있으면 캐시된 결과를 반환한다. 캐시된 결과가 없으면 실제 메서드를 호출하고, 그 결과를 캐시에 저장한다.

 

3. 메서드가 실행되면, 프록시는 반환된 결과를 캐시에 저장한다. 이후 동일한 메서드 호출이 발생하면, 캐시에 저장된 결과를 반환한다.

 

순으로 동작하게 됩니다.

 

 

 쉽게 말해, 프록시 객체라는 것은 원래 객체를 감싸고 있다가, 메서드 호출을 가로채서 특정 기능을 수행한 후 원래 객체를 호출하게 됩니다. 

 

 그런데 동일 클래스 내에서 @Cacheable이 적용된 메서드를 호출하면, 객체를 거치지 않기 때문에 프록시가 아닌 실제 객체의 메서드를 직접 호출하게 됩니다.

 

 이 경우 프록시의 가로채기 기능이 작동하지 않아서 캐시가 적용되지 않습니다. 구글링해보니 이러한 문제를 "self-invocation" 문제라고 부른다고 합니다.

 

 

해결 방법

두 가지의 해결 방법이 있습니다. 우선 저는 별도의 서비스 클래스를 사용하는 방식을 택했습니다.

 

(1) 별도의 클래스 생성

@Service
public class UserCacheService {

    @Cacheable(key = "#userId", value="users")
    public UserEntity findUsersById(Long userId){
        return userRepository.findById(userId)
                .orElseThrow(() -> new NoSuchElementException("사용자를 찾을 수 없습니다. userId: " + userId));
    }
}

이와 같이 UserCacheService를 만들고, 

 

@Service
public class UserService {

    @Autowired
    private UserCacheService userCacheService;

    @Transactional(readOnly = true)
    public String getUserNickname(Long userId) {
        UserEntity user = userCacheService.findUsersById(userId);
        return user.getNickname();
    }
}

이를 주입받아 메서드를 호출하였습니다.

 

 

이 방법 외에도 자기 자신을 프록시로 주입받아 호출함으로써 해결할 수 있습니다.

 

(2) 자기 자신을 프록시로 주입

@Service
public class UserService {

    @Autowired
    private UserService self;

    @Transactional(readOnly = true)
    public String getUserNickname(Long userId) {
        // 프록시를 통해 메서드를 호출
        UserEntity user = self.findUsersById(userId);
        return user.getNickname();
    }

    @Cacheable(key = "#userId", value="users")
    public UserEntity findUsersById(Long userId){
        return userRepository.findById(userId)
                .orElseThrow(() -> new NoSuchElementException("사용자를 찾을 수 없습니다. userId: " + userId));
    }
}

 

 의도적으로 자기 자신을 호출하는 것입니다. 그러면 객체를 거쳐 동작하기 때문에 캐시가 동작하게 됩니다.

 

 

 

 

2. Java 8의 localDate() 호환

레디스에 value 값으로 올릴 UserEntity는 아래와 같았습니다.

@Entity
@Getter
@Table(name = "user")
@NoArgsConstructor
public class UserEntity implements Serializable {

    @Id
    private Long id;

    @Column(length = 20)
    private String nickname;

    @Column(name="image_url", length = 100)
    private String imageUrl;

    @Column(name="age_range")
    private String ageRange;

    @Column(name="created_date")
    private LocalDateTime createdDate;
    
    ...

 

그리고 이 값을 레디스에 넣으려는데 아래와 같은 에러가 발생했습니다.

 ERROR 9475 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/]
 .[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet]
 in context with path [] threw exception [Request processing failed;
 nested exception is org.springframework.data.redis.serializer.SerializationException:
 Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` 
 not supported by default: add Module "com.fasterxml.jackson.datatype:
 jackson-datatype-jsr310" to enable handling (through reference chain:
 도메인명.user.UserEntity["createdDate"]); nested 
 exception is 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 (through reference chain: 도메인명
 .user.UserEntity["createdDate"])] with root cause

 

 에러 메시지를 읽어보면, Jackson이 Java 8의 java.time.LocalDateTime 유형을 처리할 수 없기 때문에 발생했다는 것을 알 수 있습니다. 

 

dependencies {
	implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' 
}

 그리고 에러 메시지에서 알려준대로, com.fasterxml.jackson.datatype:jackson-datatype-jsr310 모듈을 추가했습니다. 이 모듈은 Java 8의 날짜 및 시간 유형을 처리하기 위한 Jackson 모듈입니다. 

 

 

 이제 JacksonObjectMapper에 이 모듈을 등록해주어야합니다.

//RedisConfig
...

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {

    RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    // ObjectMapper에 JavaTimeModule 등록
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new JavaTimeModule());
    redisTemplate.setDefaultSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));
    
    redisTemplate.setConnectionFactory(connectionFactory);
    return redisTemplate;
}

 

public CacheManager redisCacheManager() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new JavaTimeModule());
    
    RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext
                    .SerializationPair
                    .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext
                    .SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)))
            .entryTtl(Duration.ofSeconds(60*60));

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

 

 

 

 

JSR - 310와 LocalDateTime

그렇다면 JSR-310이 무엇인가 궁금했습니다. 

 

 간략하게만 짚고 넘어가자면, Java의 기본 JDK에서 기본 날짜 클래스는 여러 문제점이 많았습니다. 가독성의 문제, 시간대 별 오차, 불변 객체가 아니라는 문제 등이 많았고 그래서 JDK의 날짜, 시간 API를 대체하는 라이브러리가 많이 나오게 되었습니다.

 

그 중 대표적인 것이 Joda-Time(http://www.joda.org/joda-time)이라는 것이었습니다. Joda-Time은 LocalDate, DateTime 등의 클래스로 지역 시간과 시간대가 지정된 시간을 구분해 시간 개념을 섬세하게 정의해 널리 쓰입니다. 

 

 

 그리고 2014년에 배포된 JDK 8에서 JSR - 310이 날짜와 시간, 일정 관리 API로 추가되었는데 이것이 Joda-Time에 많은 영향을 받았습니다.

JSR (Java Specification Request)은 자바 플랫폼에 대한 규격을 제안하거나 기술한 것

 

 JSR - 310은 자바 SE API가 자바의 현재 날짜와 시간 API를 형성하는 두 개의 기존 클래스(java.util.Date, java.util.Calendar)를 대체하는 것을 목표로 제안 된 시간 및 일정관리 API를 말합니다.

 

 

 

 

 

 

3. 직렬화 문제 ( LinkedHasmap cannot be cast to class )

레디스에 객체를 전달하기 위해 직렬화와 역직렬화를 사용했습니다. 이 과정에서 마주한 두 가지 에러가 있습니다. 

 

 그 전에 우선 Jackson 라이브러리에 대해 간단히 살펴보겠습니다.

Jackson

 Jackson은 Java 애플리케이션에서 객체를 JSON 형식으로 직렬화(Serialization)하거나 JSON 데이터를 Java 객체로 역직렬화(Deserialization)하는 데 사용되는 라이브러리입니다.

 

  간단히 말해, Java 객체와 JSON 간의 변환을 쉽게 해주는 도구입니다.

 

 Jackson을 사용하기 위해 일반적으로 ObjectMapper 클래스를 사용하는데, 이 클래스를 통해 직렬화와 역직렬화 작업을 수행합니다.

 

 

 

(1) missing type id property '@class'

ERROR 12914 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    
: Servlet.service() for servlet [dispatcherServlet] in context with path [] 
threw exception [Request processing failed; nested exception is org.springf
ramework.data.redis.serializer.SerializationException: Could not read JSON:
Could not resolve subtype of [simple type, class java.lang.Object]: missing 
type id property '@class'
at [Source: (byte[])"{[][]; line: 1, column: 291]; nested exception is 
com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve 
subtype of [simple type, class java.lang.Object]: missing type id property '@class'
at [Source: (byte[])"{[][]; line: 1, column: 291]] with root cause

 

 @class 라는 속성을 찾을 수 없다는 에러가 발생하는 것을 볼 수 있습니다. 

 

 

127.0.0.1:6379> get UserEntity::1231421
"{\"[도메인 명]"
,\"id\":1231421,\"nickname\":\"\xec\xa0\x95\xeb\x8f\x99\xea\xb5\x90\"
,\"imageUrl\":null,\"ageRange\":\"0\",
\"createdDate\":[2024,4,9,20,21,28]}"

 

실제 레디스에 들어간 값을 보면 @class 속성이 존재하지 않습니다. 해당하는 객체가 어떤 값인지, Jackson에서 인식하지 못하는 것을 알 수 있습니다.

 

 

 

@JsonTypeInfo(
    use = JsonTypeInfo.Id.CLASS,
    include = JsonTypeInfo.As.PROPERTY,
    property = "@class"
)
public class UserEntity {
    ...
}

 

 그래서 해당하는 객체인 UserEntity에 @JsonTypeInfo 어노테이션을 지정해줍니다. 이 어노테이션을 사용하여 JSON에 포함될 Type Identities를 지정할 수 있습니다.

 

이렇게 하면 Jackson은 JSON 데이터에서 @class 속성을 읽어 해당 객체의 실제 타입을 식별할 수 있습니다.

이후 레디스를 조회하면 @class가 정상적으로 들어간 것을 확인할 수 있습니다.

 

 

 

 

(2) LinkedHashmap cannot be cast to class 

ERROR 13134 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet]    :
Servlet.service() for servlet [dispatcherServlet] in context with path [] 
threw exception [Request processing failed; nested exception is java.lang.
ClassCastException: class java.util.LinkedHashMap cannot be cast to class 
com.kkosunnae.deryeogage.domain.user.UserEntity (java.util.LinkedHashMap is
in module java.base of loader 'bootstrap'; com.kkosunnae.deryeogage.domain.
user.UserEntity is in unnamed module of loader org.springframework.boot.
devtools.restart.classloader.RestartClassLoader @42a4511a)] with root cause

java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast 
to class com.kkosunnae.deryeogage.domain.user.UserEntity (java.util.LinkedHashMap
is in module java.base of loader 'bootstrap';

 

분명히 @class 타입이 제대로 들어갔는데 계속해서 LinkedHashMap이 변환되어야할 class 로 변환되지 않는 에러가 발생했습니다.

 

레디스 직렬화 오류를 검색하면 대부분이 이 오류였지만, 대부분의 방법으로 해결이 되지가 않았습니다.

 

 문제의 원인을 이리저리 구글링을 통해 알아본 결과 ObjectMapper가 기본적으로 직렬화/역직렬화 시 class type 정보를 포함하지 않기 때문에, 직렬화된 데이터에는 type 정보가 존재하지 않고, 역직렬화 시에도 ObjectMapper가 type 정보를 모른 채 역직렬화를 진행하게 되어, 기본 타입인 LinkedHashMap으로 역직렬화되면서 에러가 발생한다는 것을 알 수 있었습니다.

 

 

 그렇다면 어떻게 원본의 객체를 그대로 전달할 수 있을까를 이리저리 찾아보다, stackoverflow에서 해결책을 찾을 수 있었습니다. (스택오버플로우 만세..!)

 

https://stackoverflow.com/questions/49016372/spring-data-redis-cacheable-java-lang-classcastexception-java-util-linkedhashm

 

Spring-data-redis @Cacheable java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to MyObject

I am using spring-data-redis for caching data in my spring boot app. I am using Mongo as my primary data source and Redis as a cache. When I hit the API for the first time, it fetches record from M...

stackoverflow.com

 

 

objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

 

 위를 참고하여, ObjectMapper를 구성할 때, 위의 코드를 추가했습니다.  이 코드는 Jackson ObjectMapper를 구성하여 다형성을 활성화하고 객체의 유형 정보를 유지하게끔합니다.

 

간단히만 살펴보면, 여기서 activateDefaultTyping() 메서드는 Jackson의 객체를 직렬화하고 역직렬화하는 동안 객체의 유형 정보를 유지하는 기능입니다. 

 

그리고 getPolymorphicTypeValidator()는 Jackson에서 기본으로 제공하는 타입 유효성 검사기를 반환하는 메서드인데 , activateDefaultTyping 메서드를 사용하여 기본 다형성 타입 처리를 활성화하는 것이라고 합니다.

 

 ObjectMapper.DefaultTyping.NON_FINAL은 Jackson이 객체 유형 정보를 유지하기 위해 사용하는 방법을 정해주는 것인데, 여기서 NON_FINAL은 유형 정보를 유지하지만 클래스가 최종이 아닌 경우에만 유지하도록 합니다.

 

JsonTypeInfo.As.PROPERTY는 유형 정보를 직렬화된 JSON에서 속성으로 유지하는 것으로, 객체의 유형을 나타내는 속성을 JSON에 포함하게 합니다.

 

전체 적용한 코드는 redisCacheManager에서 적용을 했고 아래와 같습니다.

@Configuration
@RequiredArgsConstructor
public class CacheConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public CacheManager redisCacheManager() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext
                        .SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext
                        .SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)))
                .entryTtl(Duration.ofSeconds(60*60));

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

 

 이 방식으로 레디스에서 객체를 역직렬화할 때의 ClassCastException 문제를 피할 수 있었습니다.

 

 

 

4. 무한 재귀 문제 , @JsonIgnore

 ERROR 9633 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    
 : Servlet.service() for servlet [dispatcherServlet] in context with path 
 [] threw exception [Request processing failed; nested exception is 
 org.springframework.data.redis.serializer.SerializationException: 
 Could not write JSON: Infinite recursion (StackOverflowError) 
 (through reference chain: [도메인 명].board.BoardEntity["user"]
 ->[도메인 명].user.UserEntity["boards"]->org.hibernate.collection
 .internal.PersistentBag[0]->[도메인 명].board.BoardEntity["user"]
 ->[도메인 명].user.UserEntity["boards"]->org.hibernate.collection
 .internal.PersistentBag[0]->[도메인 명].board.BoardEntity["user"]
 -> ....

 

레디스에 데이터를 올리기 위해 객체를 직렬화하려하니, 무한으로 서로를 참조하는 무한 재귀가 발생했습니다. 

 

 

@Entity
@Getter
@Table(name = "user")
@NoArgsConstructor
public class UserEntity implements Serializable {

    @Id
    private Long id;

    @Column(length = 20)
    private String nickname;

    @Column(name="image_url", length = 100)
    private String imageUrl;

    @Column(name="age_range")
    private String ageRange;

    @Column(name="created_date")
    private LocalDateTime createdDate;
	
	...


    @OneToMany(mappedBy = "user")
    private List<BoardEntity> boards = new ArrayList<>();
    
	...

 

@Getter
@NoArgsConstructor
@Entity
@Table(name = "board")
public class BoardEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="user_id")
    private UserEntity user;
    
    ...

 

 해당하는 코드들을 살펴보면 두 객체가 서로를 참조하는 것을 볼 수 있습니다. 그래서 Jackson이 객체를 Json으로 변환하려 할때 루프에 빠지게 되는 것입니다. 

 

 

@Entity
@Getter
@Table(name = "user")
@NoArgsConstructor
public class UserEntity implements Serializable {

    @Id
    private Long id;
    
	...

    @JsonIgnore
    @OneToMany(mappedBy = "user")
    private List<BoardEntity> boards = new ArrayList<>();
    
	...

 

 이것은 @JsonIgnore를 통해 해결했습니다. 객체 직렬화에서 해당 필드를 무시하도록 지정했습니다. 유저를 인증하는 목적이다보니 해당 필드가 필요하지는 않았기 때문입니다.

 

 만약 해당 객체를 참조해야한다면, @JsonManagedReference 및 @JsonBackReference나 @JsonIdentityInfo 등을 사용할 수 있습니다. 

 

 

 

 

참고자료

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html

https://d2.naver.com/helloworld/645609

https://mangkyu.tistory.com/179

https://velog.io/@bagt/Redis-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94-%EC%82%BD%EC%A7%88%EA%B8%B0-feat.-RedisSerializer

https://ryumodrn.tistory.com/45

https://hipopatamus.tistory.com/127

https://stackoverflow.com/questions/49016372/spring-data-redis-cacheable-java-lang-classcastexception-java-util-linkedhashm