얼마 전 @RedisHash를 통해 redis를 사용하는 과정에서 @Indexed 어노테이션을 통해 secondary index를 적용한 필드에 대해 ttl(time to live)가 적용되지 않는 현상을 해결하는 방법에 대한 포스팅을 하였는데요.
2023.05.26 - [Programming/Spring Boot] - RedisHash 사용 시 @Indexed 필드 TTL(timeToLive) 적용 안되는 문제
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
간략하게 정리하자면 위 어노테이션을 추가하였을 때 '~:phantom' 키를 통해 RedisKeyExpiredEvent가 전파되는 방식으로 @Indexed 어노테이션이 적용된 secondary index도 삭제되는 방식이었습니다.
그리고 최근에도 같은 방식을 적용하여 redis를 사용하고 있었는데요.
위 방식대로라면 정상적으로 삭제되어야 할 secondary index들이 삭제되지 않고 계속 쌓이고 있는 문제를 발견했습니다.
이처럼 삭제되어야 할 데이터가 삭제되지 않는 경우 용량이 계속해서 쌓이기 때문에 언젠가는 문제가 될 수밖에 없는데요.
테스트용으로 만든 프로젝트에서는 이상 없이 동작했었기 때문에 혹시나 버전이 문제인가도 생각해 봤으며, 버전에 따른 테스트도 해봤지만 같은 버전인데도 테스트용 프로젝트에서는 데이터가 정상적으로 삭제되고, 문제가 생기는 프로젝트에서는 데이터가 계속 남아있는 상황이 확인되었습니다.
그리고 결국 발견한 문제의 원인은 @RedisHash 어노테이션에 적용되는 'keyspace' 때문이었는데요.
아래는 문제의 원인을 파악하기 위한 디버깅 과정과 @RedisHash keyspace 적용 시 주의할 점에 대해 정리한 내용입니다.
디버깅을 통한 원인 파악
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) 어노테이션을 적용했을 때, 분명 위 이미지처럼 secondary index가 정상적으로 지워지는 것을 확인했었기 때문에 같은 버전의 'org.springframework.boot:spring-boot-starter-data-redis'를 사용하면서도 이러한 문제가 발생하는 것은 동작 과정에서 어떠한 문제가 있을 것이라고 생각되었는데요.
공식 문서와 github issues 등을 참고하며 'RedisKeyValueAdapter' 클래스에서 관련 동작을 한다는 것을 알게 되었고, 해당 클래스부터 디버깅하면서 어디서 문제가 발생하는지를 찾아보았습니다.
@AllArgsConstructor
@NoArgsConstructor
@RedisHash(value = "shop:coupon", timeToLive = 10)
public class Coupon {
@Id
private String id;
private String name;
@Indexed
private String code;
}
(테스트에 사용된 Coupon class입니다. 적용된 keyspace는 'shop:coupon'입니다.)
먼저 데이터 삽입 요청 시 실행되는 put() 메서드입니다.
해당 메서드는 중간 부분에 redisOps.execute((RedisCallback<Object>) connection -> { ... } 으로 수행되는 람다식 메서드 부분을 살펴보면, 'createKey()' 메서드를 통해 objectKey를 생성하는 것을 확인할 수 있는데요.
* redisOps는 RedisOperations의 인스턴스로 RedisOperations는 RedisTemplate에서 구현한 기본 Redis 작업 내용이 지정된 인터페이스입니다.
createKey() 메서드의 내부 동작을 살펴보면 @RedisHash 어노테이션의 value 속성으로 등록된 keyspace + ":" + id 값이 objectKey가 된다는 것을 알 수 있습니다.
* keyspace가 'shop:coupon'으로 잡히고 있는 것에 주목해 주세요.
이어서 expirationEvent 발생 시 실행되는 onMessage() 메서드입니다.
(해당 메서드는 RedisMessageListenerContainer 클래스의 processMessage() 메서드를 타고 들어와서 실행됩니다.)
onMessage() 메서드에서 중요한 부분은 바로 파라미터로 들어온 message를 통해 RedisKeyExpiredEvent 인스턴스를 만들어서 다시 redis에 콜백을 보내는 부분인데요.
여기서 sRem() 메서드와 removeKeyFromIndexes() 메서드를 통해 현재 redis에서 지워지지 않고 남아있는 secondary index 및 set 데이터를 지운다는 것을 알 수 있습니다.
***
바로 이 부분에서 오류가 발생했던 원인을 찾을 수 있었는데요.
sRem(), removeKeyFromIndexes() 메서드 모두 RedisKeyExpiredEvent의 인스턴스에서 getKeyspace() 메서드를 통해 데이터를 삭제할 keyspace를 가져오는데, 이때 가져오는 키가 데이터를 넣을 때와 달랐던 것입니다.
*분명 위 creatKey() 메서드에서는 keyspace가 'shop:coupon'이었는데, 여기서는 'shop'으로 잡히고 있습니다.
원인은 파악이 되었고, 가져오는 keyspace가 다른 이유를 확인하기 위해 RedisKeyExpiredEvent 생성자를 살펴보면, 내부적으로 BinaryKeyspaceIdentifier.of() 메서드를 통해 objectId를 만드는 것을 볼 수 있는데요.
다시 BinaryKeyspaceIdentifier 클래스의 'of()' 메서드를 살펴보면 keyspaceEndIndex를 가져오는 부분에서 DELIMITER ':'를 기준으로 keyspace를 잘라오는 것을 확인할 수 있습니다.
정리하자면 @RedisHash 어노테이션의 value 속성을 통해 'shop:coupon'이라는 keyspace를 사용하였는데요.
redis에 데이터를 넣을 때는(위에서 살펴본 put method) 'shop:coupon'이라는 keyspace를 통해 데이터를 넣었지만, secondary index를 삭제하기 위한 RedisKeyExpiredEvent를 생성할 때는(onMessage method) 앞에 있는 DELIMITER ':'를 기준으로 keyspace를 잘라냈기 때문에 'shop'이라는 keyspace로 redis에 secondary index 및 set 삭제 요청을 보내게 되고 keyspace가 다르기 때문에 데이터가 삭제되지 않고 계속 남아있던 것입니다.
*** 결론
@RedisHash를 사용하며 @Indexed 어노테이션으로 secondary index를 사용할 때 keyspace에 delimiter(:)가 들어가는 경우 secondary index 및 set이 삭제되지 않습니다.
해당 내용에 대해 추가적으로 궁금하신 부분은 댓글 남겨주시면 확인하여 답변드리겠습니다. 감사합니다.