스프링부트

Spring Data Redis - RedisTemplate의 HashOperations

알쓸개잡 2024. 4. 29. 00:41

RedisTemplate의 HashOperations는 Spring Data Redis의 모듈의 일부로써 해시 작업을 처리한다. Redis 해시는 단일 Redis 키 아래에 저장되는 키-값 쌍의 모음이다. 이는 애플리케이션 간에 전송되는 객체나 맵 데이터와 같은 데이터세트를 나타내는데 적합하다.

 

RedisTemplate의 ValueOperations에 대한 내용은 아래 포스팅에 정리하였다.

2024.04.24 - [스프링부트] - Spring Data Redis - RedisTemplate의 ValueOperations

 

코드를 통해서 HashOperations 에서 제공하는 메서드를 알아보자.

 

테스트 엔티티 클래스

테스트 코드에 사용된 Person 클래스는 다음과 같다.

@Builder
public record Person(@Id UUID id,
                     String personName,
                     Integer personAge,
                     String personNation,
                     String comment) {

    public Person( UUID id, 
                   String personName, 
                   Integer personAge, 
                   String personNation, 
                   String comment ) {
        this.id = (id == null) ? UUID.randomUUID() : id;
        this.personName = personName;
        this.personAge = personAge;
        this.personNation = personNation;
        this.comment = comment;
    }

    public Person modifyPersonName(String personName) {
        return Person.builder()
                .id(this.id)
                .personName(personName)
                .personAge(this.personAge)
                .personNation(this.personNation)
                .comment(this.comment)
                .build();
    }

    public Map<String, Object> getMap() {
        return Map.ofEntries(
                Map.entry( "id", this.id ),
                Map.entry( "personName", this.personName ),
                Map.entry( "personAge", this.personAge ),
                Map.entry( "personNation", this.personNation ),
                Map.entry( "comment", this.comment )
        );
    }

    ...
}

Person 객체 자체를 RestTemplate의 HashOperations를 이용하여 hash 타입으로 저장하기 위해서는 각 필드를 Map으로 담아서 저장해야 하기 때문에 각 필드에 대해서 Map으로 만들어서 리턴하는 getMap() 메서드를 만들었다.

putAll()

void putAll(H key, Map<? extends HK, ? extends HV> m) m 으로 제공된 Map 안에 있는 키-값 쌍들을 key 에 저장한다.

Person 엔티티 클래스를 저장하는 코드다.

아래 코드는 다음부터 소개될 테스트 코드 이전에 Person 인스턴스를 저장한다.

final UUID personId = UUID.randomUUID();
final String key = "persons:" + personId;
HashOperations<String, String, Object> hashOperations;

@BeforeEach
@DisplayName( "Person 인스턴스를 해시맵 타입으로 redis에 저장" )
void save_person() throws IllegalAccessException {
    Person person = Person.builder()
            .id( personId )
            .personName( "test-name" )
            .personAge( 100 )
            .personNation( "korea" )
            .comment( "first commit data" )
            .build();

    Map<String, Object> personMap = person.getMap();
    /* getMap() 대신에 아래와 같이 reflection방식으로 Map을 만들어서 저장할 수도 있다.
    Map<String, Object> personMap = new HashMap<>();
    Class<? extends Person> personClass = person.getClass();
    Field[] fields = personClass.getDeclaredFields();
    for ( Field field : fields ) {
        field.setAccessible( true );
        personMap.put( field.getName(), field.get( person ) );
    }
    */
    //person 저장
    hashOperations = redisTemplate.opsForHash();
    hashOperations.putAll( key, personMap );
}

Redis에 저장된 결과는 다음과 같다.

127.0.0.1:6379> hgetall persons:da13f8e2-79b2-4dec-bfaf-eafe3b504b0b
 1) "personName"
 2) "\"test-name\""
 3) "comment"
 4) "\"first commit data\""
 5) "id"
 6) "\"da13f8e2-79b2-4dec-bfaf-eafe3b504b0b\""
 7) "personNation"
 8) "\"korea\""
 9) "personAge"
10) "100"

1, 3, 5, 7, 9 홀수 필드는 hash 키(필드명)를 의미하며 2, 4, 6, 8, 10 짝수 필드는 값을 의미한다.

 

delete(), hasKey()

void delete(H key, Object ... hashKeys) key에 해당하는 해시 필드 중에서 hashKeys에 해당하는 항목을 삭제한다.
Boolean hasKey(H key, Object hashKey) key에 해당하는 해시 필드 중에서 hashKey에 해당하는 해시키가 존재하는지 체크한다.
@Test
@DisplayName( "특정 해시맵의 엔트리 삭제 + 해시 key 체크 테스트 - comment, personNation 필드 삭제" )
void delete_haskey_test() {
    final List<String> deleteHashKeyList = List.of( "comment", "personNation" );

    Long deleteCount = hashOperations.delete( key, "comment", "personNation" );
    Assertions.assertThat( deleteCount ).isNotNull();
    Assertions.assertThat( deleteCount ).isEqualTo( deleteHashKeyList.size() );

    Boolean exists = hashOperations.hasKey( key, deleteHashKeyList.getFirst() );
    Assertions.assertThat( exists ).isFalse();
    exists = hashOperations.hasKey( key, deleteHashKeyList.getLast() );
    Assertions.assertThat( exists ).isFalse();
}

comment, personNation 해시키를 삭제한다.

comment, personNation이 삭제된 결과는 다음과 같다.

127.0.0.1:6379> hgetall persons:da13f8e2-79b2-4dec-bfaf-eafe3b504b0b
1) "personName"
2) "\"test-name\""
3) "id"
4) "\"da13f8e2-79b2-4dec-bfaf-eafe3b504b0b\""
5) "personAge"
6) "100"

 

entries()

Map<HK, HV> entries(H key) key에 저장된 전체 해시 정보를 리턴한다.
@Test
@DisplayName( "해시맵 엔트리 목록을 가져오는 테스트" )
void entries_test() {
    Map<String, Object> entries = hashOperations.entries( key );
    Assertions.assertThat( entries ).isNotNull();
    Assertions.assertThat( entries.size() ).isEqualTo( 5 );
    Assertions.assertThat( entries.get( "id" ) ).isEqualTo( personId.toString() );
}

entries 에 저장된 엔트리 목록은 다음과 같다.

"id" -> "3f40958c-8936-4ec3-b4ae-a0dab8131e6b"
"personNation" -> "korea"
"personAge" -> 100
"personName" -> "test-name"
"comment" -> "first commit data"

 

get()

HV get(H key, Object hashKey) key에 저장된 해시 데이터 중에서 hashKey를 가진 필드 값을 리턴한다.
@Test
@DisplayName( "특정 해시맵 엔트리의 값을 조회하는 테스트" )
void get_test() {
    Object hashValue = hashOperations.get( key, "id" );
    Assertions.assertThat( hashValue ).isNotNull();
    Assertions.assertThat( hashValue ).isEqualTo( personId.toString() );

    //조회하는 해시 키가 없는 경우 null을 리턴한다.
    hashValue = hashOperations.get( key, "noField" );
    Assertions.assertThat( hashValue ).isNull();
}

존재하지 않는 해시 키를 조회했을 때 null을 리턴한다.

 

increment()

Double increment(H key, HK hashKey, double delta) hashKey의 소수점 타입의 값을 delta 만큼 증가시킨다.
리턴은 delta 만큼 증가된 값이다.
Long increment(H key, HK hashKey, long delta) hashKey의 정수형 타입의 값을 delta 만큼 증가시킨다.
리턴은 delta 만큼 증가된 값이다.
@Test
@DisplayName( "정수형 혹은 소수점 타입의 특정 엔트리의 값을 증가시키는 테스트" )
void increment_test() {
    //현재 personAge는 100인 상태에서 personAge값을 10 증가시키고 증가된 값을 리턴한다.
    Long personAge = hashOperations.increment( key, "personAge", 10 );
    Object hashValue = hashOperations.get( key, "personAge" );
    Assertions.assertThat( hashValue ).isNotNull();
    Assertions.assertThat( hashValue ).isInstanceOf( Integer.class );
    Assertions.assertThat( hashValue ).isEqualTo( personAge.intValue() );
}

 

keys()

Set<HK> keys(H key) key에 저장된 모든 hash 필드의 hash key 목록을 리턴한다.
@Test
@DisplayName( "해시맵의 모든 엔트리의 키 목록을 조회하는 테스트" )
void keys_test() {
    Set<String> hashKeys = hashOperations.keys( key );
    Assertions.assertThat( hashKeys ).isNotNull();
    Assertions.assertThat( hashKeys.size() ).isEqualTo( 5 );
    Assertions.assertThat( hashKeys.contains( "id" ) ).isTrue();
    Assertions.assertThat( hashKeys.contains( "personName" ) ).isTrue();
    Assertions.assertThat( hashKeys.contains( "personAge" ) ).isTrue();
    Assertions.assertThat( hashKeys.contains( "personNation" ) ).isTrue();
    Assertions.assertThat( hashKeys.contains( "comment" ) ).isTrue();
}

 

put()

void put(H key, HK hashKey, HV value) hashKey의 값을 value로 변경한다.
hashKey가 없으면 해시 엔트리를 추가한다.

 

@Test
@DisplayName( "해시맵에 엔트리를 추가하거나 엔트리의 값을 수정하는 테스트" )
void put_test() {
    hashOperations.put( key, "personName", "modified-name" );
    Object hashValue = hashOperations.get( key, "personName" );
    Assertions.assertThat( hashValue ).isNotNull();
    Assertions.assertThat( hashValue ).isEqualTo( "modified-name" );

    //존재하지 않는 엔트리 키에 대해서 put을 하면 새로운 엔트리를 추가한다.
    hashOperations.put( key, "noField", "this field added" );
    hashValue = hashOperations.get( key, "noField" );
    Assertions.assertThat( hashValue ).isNotNull();
    Assertions.assertThat( hashValue ).isEqualTo( "this field added" );
}

Redis에 저장된 결과는 다음과 같다.

127.0.0.1:6379> hgetall persons:b6e6ce63-8051-4368-b0c4-a6060b2cb204
 1) "comment"
 2) "\"first commit data\""
 3) "personName"
 4) "\"modified-name\""
 5) "personAge"
 6) "100"
 7) "personNation"
 8) "\"korea\""
 9) "id"
10) "\"b6e6ce63-8051-4368-b0c4-a6060b2cb204\""
11) "noField"
12) "\"this field added\""

personName의 값은 'modified-name'으로 변경되었고, 기존에 존재하지 않던 noField 엔트리가 추가되었다.

 

putIfAbsent()

Boolean putIfAbsent(H key, HK hashKey, HV value) hashKey가 존재하지 않는 경우에만 <hashKey, value> 엔트리를 추가한다. hashKey가 존재하는 경우 False가 리턴되고 값은 변경되지 않는다.
@Test
@DisplayName( "해시맵에 엔트리 키가 존재하지 않는 경우에만 엔트리를 추가하는 테스트" )
void putIfAbsent_test() {
    //personName 키가 존재하므로 putIfAbsent 결과는 false다.
    Boolean result = hashOperations.putIfAbsent( key, "personName", "modified-name" );
    Object hashValue = hashOperations.get( key, "personName" );
    Assertions.assertThat( result ).isFalse();
    Assertions.assertThat( hashValue ).isNotEqualTo( "modified-name" );

    //존재하지 않는 엔트리 키에 대해서 putIfAbsent 결과는 true다.
    result = hashOperations.putIfAbsent( key, "noField", "this field added" );
    Assertions.assertThat( result ).isTrue();
    hashValue = hashOperations.get( key, "noField" );
    Assertions.assertThat( hashValue ).isNotNull();
    Assertions.assertThat( hashValue ).isEqualTo( "this field added" );
}

Redis에 저장된 결과는 다음과 같다.

127.0.0.1:6379> hgetall persons:b7d336e9-303e-4fa7-9f45-f5250c7504cb
 1) "id"
 2) "\"b7d336e9-303e-4fa7-9f45-f5250c7504cb\""
 3) "comment"
 4) "\"first commit data\""
 5) "personName"
 6) "\"test-name\""
 7) "personAge"
 8) "100"
 9) "personNation"
10) "\"korea\""
11) "noField"
12) "\"this field added\""

이미 존재하는 personName 엔트리에 대해서는 값이 수정되지 않았고, 기존에 존재하지 않는 noField 필드에 대해서만 엔트리가 추가되었다.

 

size()

Long size(H key) key에 저장된 해시 엔트리 크기를 리턴한다.
@Test
@DisplayName( "해시맵의 엔트리 수를 조회하는 테스트" )
void size_test() {
    Long size = hashOperations.size( key );
    Assertions.assertThat( size ).isNotNull();
    Assertions.assertThat( size ).isEqualTo( 5L );
}

 

values()

List<HV> values(H key) key에 저장된 해시 엔트리의 모든 값을 리스트로 리턴한다.
@Test
@DisplayName( "해시의 엔트리에 저장된 값 목록을 조회하는 테스트" )
void values_test() {
    List<Object> values = hashOperations.values( key );
    Assertions.assertThat( values ).isNotNull();
    Assertions.assertThat( values.size() ).isEqualTo( 5 );
}

Redis에 저장된 데이터는 다음과 같다.

127.0.0.1:6379> hgetall persons:f0b3cb55-2d04-401f-a354-c1d8510576ae
 1) "id"
 2) "\"f0b3cb55-2d04-401f-a354-c1d8510576ae\""
 3) "comment"
 4) "\"first commit data\""
 5) "personName"
 6) "\"test-name\""
 7) "personAge"
 8) "100"
 9) "personNation"
10) "\"korea\""

values() 호출 결과 values 리스트의 값은 다음과 같다.

values =  size = 5
 0 = "f0b3cb55-2d04-401f-a354-c1d8510576ae"
 1 = "first commit data"
 2 = "test-name"
 3 = 100
 4 = "korea"

Redis에 저장된 순서대로 값을 저장하는 것을 알 수 있다.

 

scan()

Cursor<Map.Entry<HK, HV>> scan(H key, ScanOptions options) key에 저장된 전체 데이터 세트를 반복적으로 검색한다.

scan() 메서드는 전체 데이터 세트를 한 번에 메모리에 로드하지 않고 대규모 Redis 해시에서 항목을 반복적으로 검색하는데 주로 사용한다. 대량의 데이터를 한 번에 가져오거나 조작하려고 할 때 발생할 수 있는 심각한 성능 저하와 잠재적인 메모리 부족 오류를 방지하는데 도움이 되기 때문에 매우 큰 해시로 작업할 때 특히 유용하다.

 

ScanOptions 필드는 검색 작업을 구성하는 데 사용된다. ScanOptions에서 사용할 수 있는 각 필드는 다음과 같다.

  • pattern
    • 지정된 패턴을 기반으로 키 또는 요소를 필터링한다.
  • count
    • batch 당 반환되는 요소 수를 제안한다. Redis는 커서에 대한 각 호출에 대해 count 옵션에 지정된 요소 수를 반환하려고 시도하지만 이것은 보장되지는 않는다. 데이터 전송 비용에 비해서 각 스캔 명령 비용이 높은 환경에서는 개수가 많을수록 효율성이 향상된다.

ScanOptions.NONE 은 스캔 작업에 키 패턴을 기반으로 필터링을 적용하지 않도록 하여 모든 키나 요소를 반복한다.

@Test
@DisplayName( "scan() 테스트" )
void scan_test() {
    try( Cursor<Map.Entry<String, Object>> cursor =
                 hashOperations.scan( key, ScanOptions.NONE ) ) {
        while ( cursor.hasNext() ) {
            Map.Entry<String, Object> next = cursor.next();
            System.out.println("key: " + next.getKey() + " value: " + next.getValue());
            switch ( next.getKey() ) {
                case "id" ->
                        Assertions.assertThat( next.getValue() ).isEqualTo( personId.toString() );
                case "personName" ->
                        Assertions.assertThat( next.getValue() ).isEqualTo( "test-name" );
                case "personAge" ->
                        Assertions.assertThat( next.getValue() ).isEqualTo( 100 );
                case "personNation" ->
                        Assertions.assertThat( next.getValue() ).isEqualTo( "korea" );
                case "comment" ->
                        Assertions.assertThat( next.getValue() ).isEqualTo( "first commit data" );
            }
        }
    }
}

Cursor 클래스는 Closeable 하다.

ScanOptions.NONE은 아무 필터링을 하지 않으므로 다음과 같이 모든 필드에 대해서 반복적으로 가져온다.

key: comment value: first commit data
key: personName value: test-name
key: personAge value: 100
key: personNation value: korea
key: id value: 3d991eb3-ee84-42cd-bf56-04a811d37cc2

 

다음과 같이 ScanOptions에 'person*' 과 같이 pattern을 지정한 경우 person~ 키 필드에 대해서만 엔트리를 스캔한다.

ScanOptions scanOptions = ScanOptions.scanOptions().match( "person*" ).build();
try( Cursor<Map.Entry<String, Object>> cursor =
             hashOperations.scan( key, scanOptions ) ) {
 	...            
}

실행 결과는 다음과 같다.

key: personNation value: korea
key: personName value: test-name
key: personAge value: 100

key에 person prefix가 붙은 해시 필드에 대해서만 스캔함을 알 수 있다.

 

randomEntries(), randomEntry(), randomKey(), randomKeys()

Map<HK, HV> randomEntries(H key, long count) key에 저장된 해시 엔트리를 random하게 count만큼 리턴한다.
count가 해시 엔트리 개수 보다 크면 전체 엔트리가 리턴된다.
Map.Entry<HK, HV> randomEntry(H key) key에 저장된 해시 엔트리 중에서 random하게 엔트리를 하나 리턴한다.
HK randomKey(H key) key에 저장된 해시 엔트리 중에서 random하게 해시키를 하나 리턴한다.
List<HK> randomKeys(H key, long count) key에 저장된 해시 엔트리 중에서 random하게 count 개수만큼 해시키를 리턴한다.
count가 해시 엔트리 개수 보다 크면 전체 엔트리 키가 리턴된다.
@Test
@DisplayName( "randomEntries, randomEntry, randomKey, randomKeys 테스트" )
void random_method_test() {
    Map<String, Object> randomEntries = hashOperations.randomEntries( key, 2 );
    Assertions.assertThat( randomEntries ).isNotNull();
    Assertions.assertThat( randomEntries.size() ).isEqualTo( 2 );

    Map.Entry<String, Object> entry = hashOperations.randomEntry( key );
    Assertions.assertThat( entry ).isNotNull();

    Set<String> keys = hashOperations.keys( key );
    String randomKey = hashOperations.randomKey( key );
    Assertions.assertThat( randomKey ).isNotNull();
    Assertions.assertThat( keys ).contains( randomKey );

    List<String> randomKeys = hashOperations.randomKeys( key, 2 );
    Assertions.assertThat( randomKeys ).isNotNull();
    Assertions.assertThat( randomKeys.size() ).isEqualTo( 2 );
    Assertions.assertThat( keys ).containsAll( randomKeys );
}

 

샘플 코드는 GITLAB 링크에서 확인할 수 있다.

끝.