Spring Data Redis - RedisTemplate의 HashOperations
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 링크에서 확인할 수 있다.
끝.