[Spring] @RedisHash 편하게 사용할 수 있지만 주의해야한다고요?

Hocaron·2024년 6월 14일
3
post-thumbnail
post-custom-banner

Redis는 빠르고, 메모리 기반으로 동작하는 데이터 저장소로, 많은 프로젝트에서 유용하게 쓰이고 있다. Spring Data Redis를 사용하면 이런 Redis를 쉽게 통합할 수 있고, @RedisHash는 자바 객체를 Redis 해시 구조에 매핑하는 걸 아주 쉽게 만들어 줘서 데이터를 직관적으로 다룰 수 있다. 특히, Spring Data의 CrudRepository 인터페이스와 함께 사용하면, 기본적인 CRUD 기능을 쉽게 구현할 수 있다.

하지만 예상하지 못한 명령어가 실행되거나 인덱싱을 위해 존재하는 삭제되지 않아 데이터가 무제한으로 쌓일 수 있다. 아래 예제로 주의해야하는 부분을 살펴보자.

예제 코드는 다음과 같아요.

@Getter
@RedisHash(value = "person")
public class Person {

    @Id
    private String id;

    private String name;

    @Indexed
    private Integer age;

    @TimeToLive
    private long ttl = 60;

    public Person(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
}

@Id, @Indexed 는 어떻게 동작할까?

Redis는 Primary Key 접근만 지원하여 구조가 간단하고 성능이 뛰어나지만, 특정 필드 검색 기능이 제한적이다. Spring Data Redis Repository는 이러한 한계를 Set을 사용해 극복한다.

데이터 저장 시 별도의 Set 에 key와 ID를 저장하고, 조회 시 해당 Set에서 데이터를 빠르게 검색한다. MySQL 인덱스를 떠올리면 될 것 같다.

로직이 수행될 때, 저장되는 데이터와 수행되는 명령어를 상상해보자.

    @Test
    void test() {

        var personId = personRedisRepository.save(new Person("ho", 20)).getId();

        personRedisRepository.findById(personId);

        personRedisRepository.findAllByAge(20);
    }

Hash 자료구조 저장 및 조회를 위해 HMSET, HGETALL 이 수행될 것 같다. 인덱싱을 위한 Set 구조 저장 및 조회를 위해 SADD, SINTER 도 수행이 될 것 같다.

redis 서버로 들어온 명령어 목록은 어떻게 확인할 수 있을까?

$ redis-cli monitor

위 명령어를 통해서 수행되는 명령어 확인이 가능하다.

예상과 다르게 많이 수행되는 명령어

모니터링을 통해 실제로 수행되는 명령어를 살펴보자.

# save operation
"SISMEMBER" "person" "aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60"
"DEL" "person:aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60"
"HMSET" "person:aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60" "age" "20" "id" "aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60" "name" "ho" "ttl" "60"
"SADD" "person" "aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60"
"EXPIRE" "person:aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60" "60"
"SADD" "person:age:20" "aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60"
"SADD" "person:aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60:idx" "person:age:20"

# findById operation
"HGETALL" "person:aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60"
"TTL" "person:aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60"

# findAllByAge operation
"SINTER" "person:age:20"
"HGETALL" "person:aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60"
"HGETALL" "person:38034ca4-039b-468a-9da9-8083237b74ca"

명령어의 역할은 다음과 같다.

  • save operation
    • SISMEMBER : 특정 키가 Set에 존재하는지 여부를 확인하는 데 사용된다.
    • DEL: 해당 키에 저장된 데이터를 삭제한다.
    • HMSET : 해시 구조를 사용하여 여러 필드를 한 번에 설정한다.
    • SADD : 지정된 Set(person)에 지정된 값(aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60)을 추가한다.
    • EXPIRE : 키의 유효 기간을 설정하여 자동으로 만료되도록 한다.
    • SADD : 지정된 Set(person:age:20)에 지정된 값(aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60)을 추가한다.
    • SADD : 인덱싱 Set(person:aa53cd7f-8a4e-4247-b6f3-ab43a8d7ad60:idx)에 값(person:age:20)을 추가한다.
  • findById operation
    • HGETALL : 해시의 모든 데이터를 조회한다.
    • TTL : 키의 만료 시간 확인에 사용된다.
  • findAllByAge operation
    • SINTER : 특정 조건을 만족하는 요소들을 조회한다.
    • HGETALL : 해시의 모든 데이터를 조회한다.

데이터를 저장하기 전에도 무조건 SISMEMBER, DEL 명령어가 수행된 후에 HMSET 이 수행되는 등의 불필요한 쿼리 발생으로 이어질 수 있으며, 성능에 영향을 줄 수 있다.

또한 인덱싱 기능을 사용하면, 저장과 조회 과정에서 SADD 등의 추가적인 명령어가 발생할 수 있다.

예상과 다르게 계속 쌓이는 데이터

Hash 데이터는 TTL 이 만료되어 데이터가 삭제되었지만, SET 데이터는 삭제가 되지 않는 것을 알 수 있다.

Set 데이터가 계속 쌓여서 문제가 될 수 있다

Redis에서 기본적으로 TTL(Time-To-Live) 설정은 키 전체에 적용되며, 개별 필드에 TTL을 적용할 수는 없다.

TTL 설정이 전체 키에만 적용되기 때문에, Set에 속한 개별 요소들은 TTL이 적용되지 않는다. 즉, Set에 포함된 요소들은 Set 자체가 삭제되지 않는 한 계속해서 남아 있게 된다. 이로 인해 데이터가 계속 쌓여서 문제가 될 수 있다. 참고로 인덱싱을 위한 Set 의 TTL 설정은 만료기한이 없는 -1 이다.

해결방법에 대해서 알아보자

❎ [해결방법 1] Hash 데이터가 TTL 이 만료되면, Set 데이터도 삭제되도록 이벤트 수신하기

이러한 문제를 해결하기 위해서는 TTL이 만료된 후에도 인덱스 Set을 정리할 수 있는 메커니즘이 필요하다. 이를 위해 Redis의 Keyspace Notifications를 활용할 수 있다. Keyspace Notifications를 통해 TTL 만료 이벤트를 수신하고, 해당 이벤트가 발생했을 때 인덱스 Set에서 만료된 객체의 ID를 제거하는 작업을 수행할 수 있다.

애플리케이션이 시작되면, Spring Data Redis는 PSUBSCRIBE 명령을 사용하여 keyspace 이벤트 패턴을 구독하고, Redis에서 키가 만료되거나 삭제될 때, 해당 이벤트가 발생하면 이를 Pub/Sub 메시지로 발행한다. Spring Data Redis는 Pub/Sub을 통해 이벤트를 수신하고, 수신된 이벤트를 기반으로 애플리케이션에서 만료된 키에 대한 인덱스 데이터를 제거한다.

설정은 다음과 같다.

$ redis-cli config set notify-keyspace-events Ex

Redis 에서 만료 이벤트가 발생할 때 이를 클라이언트에 알릴 수 있도록 설정하자.

@Configuration
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
public class RedisConfig {
}

Redis의 Keyspace Notifications 설정을 추가하자.

Redis Keyspace 이벤트 설정 모드
설정 모드설명
ON_STARTUP애플리케이션이 시작될 때 Redis keyspace 이벤트를 활성화한다.
ON_DEMANDRedis에서 첫 번째 키가 TTL(Time To Live)로 설정될 때까지 Redis keyspace 이벤트를 활성화하지 않는다.
OFFRedis keyspace 이벤트를 비활성화한다.

위 설정을 적용하고 모니터링 해보자

"PSUBSCRIBE" "__keyevent@*__:expired"
"HGETALL" "person:d772a2b9-6d0c-4551-afcc-73f25997d372:phantom"
"DEL" "person:d772a2b9-6d0c-4551-afcc-73f25997d372:phantom"
"SREM" "person" "d772a2b9-6d0c-4551-afcc-73f25997d372"
"SMEMBERS" "person:d772a2b9-6d0c-4551-afcc-73f25997d372:idx"
"TYPE" "person:age:20"
"SREM" "person:age:20" "d772a2b9-6d0c-4551-afcc-73f25997d372"
"DEL" "person:d772a2b9-6d0c-4551-afcc-73f25997d372:idx"
  1. Redis 에서 키가 만료될 때 발생하는 이벤트를 구독
  2. TTL 이 완료되어 키가 삭제되었을 때 이벤트 수신
  3. person:d772a2b9-6d0c-4551-afcc-73f25997d372:phantom, person:d772a2b9-6d0c-4551-afcc-73f25997d372:idx 키로 인덱스를 삭제

person:d772a2b9-6d0c-4551-afcc-73f25997d372:idx 형식의 키는 조회에 사용되지도 않으면서 생성이 되는 이유가 궁금했는데, 인덱스 정리를 위해 필요한 부분이었다.

TTL(Time To Live) 만료 이벤트 처리 속성

설정 모드설명
ShadowCopy.DEFAULT기본값으로, 섀도우 복사본을 사용하여 만료 이벤트를 처리해. 원본 데이터가 저장될 때 복사본을 생성하고, 복사본의 TTL을 원본 TTL보다 약간 더 길게(+5분) 설정한다. 이는 이벤트 처리를 안정적으로 하기 위함이다.
ShadowCopy.OFF섀도우 복사본을 저장하지 않아. 저장 공간을 절약할 수 있지만, 이벤트 처리의 신뢰성이 떨어질 수 있다.

✅ [해결방법 2] RedisTemplate 으로 변경하기

나는 RedisTemplate으로 전환하여 해결하기로 했는데, 이유는 다음과 같다.

  1. 명령어가 예측 가능하고 안정적인 작업 수행이 가능하다.
  2. 현재 사용하고 있는 Redis Master/Replica 연결(RedisStaticMasterReplicaConfiguration)은 Pub/Sub 을 지원하지 않는다. 따라서 Pub/Sub 기반으로 동작하는 Keyspace 이벤트 설정이 불가능하다.

    마스터/레플리카 구성을 사용하여 제공된 주소를 통해 RedisConnection을 설정하는 데 사용되는 RedisConnectionFactory 설정 클래스입니다. 예를 들어, AWS ElastiCache에 읽기 레플리카를 사용하여 연결할 때 적용됩니다.
    마스터/레플리카 연결은 Pub/Sub 작업에 사용할 수 없다는 점에 유의하십시오.
    fyi; spring data redis 공식문서

  3. 기본적으로 ElastiCache는 Redis 키스페이스 알림이 비활성화되어있다. 개발자가 파라미터값을 변경해주어야 하는데, 키스페이스 활성화를 위해서 인스턴스를 다시 시작할 필요는 없지만 성능에 영향을 줄 수 있다고 한다.
    fyi; aws elasticache 가이드
  4. 데이터가 Redis String 으로 구현되어도 괜찮다. 그리고 Hash 로 구현했을 때 O(N) 연산이 필요한 HGETALL, TTL 설정을 추가적인 명령어인 EXPIRE 로 실행되어야 한다는 점을 고려한 것도 자료구조를 변경하는 원인이 되었다.

결론

  • @RedisHash 정말 편하지만, 키스페이스 이벤트 비활성화 시에는 데이터가 계속 쌓일 수 있다.
  • @RedisHash CRUD Repository 메서드 실행 시에 어떤 명령어가 실행되는지 확인할 필요가 있다.

References

profile
기록을 통한 성장을
post-custom-banner

0개의 댓글