본문 바로가기

프로젝트/트러블슈팅

@RedisHash 주의해서 사용하기

도입

프로젝트에서 @RedisHash를 사용하여 기능을 구현했다. Spring Data Redis의 @RedisHash는 자바 객체를 레디스의 Hash 데이터구조에 자동으로 매핑한다. 그리고 CrudRepository를 확장한 인터페이스를 사용하면, 데이터의 CRUD 작업을 쉽게 구현할 수 있다.

 

하지만 인덱싱을 위한 데이터는 만료시간이 지나도 삭제되지 않아 데이터가 계속 쌓이고 예상하지 못한 명령어가 발생한다. 예제를 통해 주의할 점을 자세히 알아보자.

 

@RedisHash 주의점

예제 코드

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RedisHash(value = "member")
public class Member {

    @Id
    private Long id;

    @Indexed
    private String email;

    @TimeToLive
    private Long expiresAt;

    private String name;

    @Builder
    public Member(Long id, String email, Long expiresAt, String name) {
        this.id = id;
        this.email = email;
        this.expiresAt = expiresAt;
        this.name = name;
    }
}

 

이 객체를 CrudRepository를 확장한 인터페이스를 통해 레디스에 저장했다.

@DisplayName("멤버를 저장한다.")
@Test
void saveMember() {
    // given
    Member member = Member.builder()
        .id(1L)
        .email("abc@abc.com")
        .expiresAt(100L)
        .name("memberA")
        .build();

    memberRepository.save(member);
}

 

그리고 아래와 같이 네 개의 데이터가 레디스에 생성됐다. 

레디스 내 저장된 데이터

Member 정보를 가지고 있는 것은 Hash 타입의 member:1 이다. HGETALL로 모든 필드를 확인해보면, 클래스 정보가 나타난다. 그리고 TTL은 100초로 설정되어 있다.

Hash 데이터 정보

그 외 데이터(member, member:1:idx, member:email:abc@abc.com)는 Set 타입이다. @Id와 @Indexed 어노테이션이 붙은 필드에 대해선 Set 데이터가 생성된다. 레디스의 Hash 데이터는 특정 필드에 대한 검색이 제한적이다. 따라서 필드에 대한 Set 데이터를 생성해 이 데이터를 통해 필드 검색을 빠르게 수행한다.

 

하지만 인덱싱을 위해 생성된 Set 데이터는 TTL(Time-To-Live)은 -1로 만료시간이 없다. 그리고 레디스는 Set의 요소들에 대해 TTL을 지정할 수 없다. 따라서 Hash 데이터는 만료되어 삭제되었어도 Set 데이터 내에 키 값이 남아있다. 이렇게 불필요한 데이터가 계속 쌓이면 비싼 메모리 공간을 낭비한다. 

키 값이 Set 데이터에 남음

 

그렇다면, 이 문제를 어떻게 해결할 수 있을까? Hash 데이터가 만료되면 스프링에서 Set의 요소를 삭제하는 명령어를 호출하면 된다.

레디스 설정 클래스에서 RedisKeyValueAdapter.EnableKeyspaceEvents.ON_DEMAND로 한다.

Redis 설정 클래스

 

RedisKeyValueAdapter에서 put 메서드가 호출되면서 ON_DEMAND일 경우 initKeyExpirationListener를 호출한다. 

 

그리고 KeyExpirationEventMessageListener에서 __keyevent@*__:expired를 구독한다.

키 만료 이벤트 구독

 

그리고  저장할 때 phantom 이라는 suffix가 붙은 Member의 사본 데이터를 함께 저장한다. 이 사본 데이터의 TTL은 원본 데이터의 TTL보다 조금 더 길다. 스프링은 키 만료 이벤트를 수신받아 Set에서 만료된 키의 데이터를 삭제시킬 때 사본 데이터를 이용해 삭제한다.

사본 데이터 저장

 

코드보다 레디스에 어떤 데이터가 저장되는지, 상황에 따라 어떤 명령어가 실행되는지 확인하면 이해가 빠르다.  

 

먼저, 레디스에 저장된 데이터는 아래와 같다. 사본 데이터인 member:1:phantom이 저장됐다.

레디스 내 저장된 데이터

 

그리고 monitor 명령어를 이용해 레디스에서 실행되는 명령어를 확인했다. 이를 통해 키 만료 이벤트가 발생하면 phantom suffix가 붙은 사본 데이터로 @Id와 @Indexed 정보를 가져온 뒤, Set 내 키를 삭제함을 알 수 있다.

 

127.0.0.1:6379> monitor
OK
# 이벤트 구독
"HELLO" "3"
"HELLO" "3"
"PSUBSCRIBE" "__keyevent@*__:expired"

# Member 저장
"DEL" "member:1"
"HMSET" "member:1" "_class" "com.spring.study.member.Member" "email" "abc@abc.com" "expiresAt" "100" "id" "1" "name" "memberA"
"SADD" "member" "1"
"EXPIRE" "member:1" "100"
"DEL" "member:1:phantom"
"HMSET" "member:1:phantom" "_class" "com.spring.study.member.Member" "email" "abc@abc.com" "expiresAt" "100" "id" "1" "name" "memberA"
"EXPIRE" "member:1:phantom" "400"
"SADD" "member:email:abc@abc.com" "1"
"SADD" "member:1:idx" "member:email:abc@abc.com"
"PUNSUBSCRIBE"
"PING"

# 키 만료되어 Set에서 삭제
"HGETALL" "member:1:phantom"
"DEL" "member:1:phantom"
"SREM" "member" "1"
"SMEMBERS" "member:1:idx"
"TYPE" "member:email:abc@abc.com"
"SREM" "member:email:abc@abc.com" "1"
"DEL" "member:1:idx"

 

레디스에서 실행되는 명령어를 보면 굉장히 많은 명령어가 실행되고 있음을 알 수 있다.

 

Member를 저장할 때 발생하는 명령어를 살펴보자. Id가 1번인 Member 데이터가 레디스 내에 존재하지 않음에도 불필요하게 DEL 명령어를 수행한 다음, HMSET으로 데이터를 저장하고 있다.

 

이렇게 레디스에 저장된 데이터를 애플리케이션에서 조회할 때 어떤 명령어를 호출하는지 살펴보자. 객체로 매핑하기 위해 Hash 데이터의 필드 값를 모두 가져오는 HGETALL 명령어가 실행된다. HGETALL은 필드 - 값 개수에 비례하여 실행 시간이 증가한다. 따라서 Hash의 크기가 커질 수록 주의해야 한다. 

 

조회 시 발생하는 레디스 명령어

 

그렇다면 이렇게 의도하지 않은 불필요한 오버헤드를 어떻게 제거할 수 있을까? RedisTemplate로 변경하면 된다. RedisTemplate를 이용하면 CrudRepository와 달리 Low 레벨에서 레디스를 다루기 때문에 코드량이 증가하지만, 명령어가 예측 가능하여 안정적인 작업 수행이 가능하다.

 

처음엔 프로젝트에서 @RedisHash를 사용했지만, 이러한 주의점을 인지한 뒤엔 데이터를 String형으로 구현해도 괜찮았기 때문에 RedisTemplate으로 변경했다.

 

참고