[Redis] SCAN을 사용해보자

SCAN 너는 누구니..

레디스를 DB로 사용한 개인 프로젝트 중 필요한 기능이 있었는데 레디스에서 제공하지 않아 직접 비즈니스 로직을 정의해 보며 공부했던 내용을 적어보려고 합니다.

부족한 부분에 대한 지적은 감사히 받겠습니다!

SCAN

레디스에서 SCAN을 사용하게 되면 커서를 반환한다. 이 커서를 통해서 다음 엔티티에 접근할 수 있게 된다.

일반적으로 SCAN은 MEMBERS가 가진 단점인 너무 많은 양의 데이터를 요청하면 응답 시간이 느려진다.라는 부분을 해결할 수 있다.

이 둘의 동작 차이는 다음과 같다.

Members는?

Members 요청을 하게 되면 싱글 스레드인 레디스는 블락을 걸고 모든 데이터에 대한 수집을 한 뒤 반환을 하게 된다. 이 시점에서 많은 양의 데이터를 취합해야 하기 때문에 성능적인 문제가 생긴다. 또한 블락을 걸기 때문에 싱글 스레드인 레디스에서는 Members 요청 중간에 다른 요청을 수용할 수 없게 된다.

SCAN은?

Scan 요청을 알아보기에 앞서 레디스의 자료구조에 대한 이해가 필요하다. 이번에 테스트하면서 공부한 내용을 기반으로 하면 레디스의 set / sorted set / hash의 내부 자료 구조는 hash table이나 skiplist를 기본적으로 사용한다. 이 스캔은 hash table내에 존재하는 여러 버킷들을 한번에 하나씩 순회하는 것이다. 그렇기 때문에 순회중 다른 요청이 들어와도 작업의 수행이 가능하다. 단, 단점도 존재한다. 순회가 시작된 이후로 삽입된 값은 반환될 수도, 반환되지 않을 수도 있다. 이미 커서가 지나간 부분에 삽입이 되었다면 반환되지 않을 것이고 커서가 지나가지 않은 부분에 삽입이 되었다면 반환될 것이다. 또한 같은 항목이 여러번 반환될 수 있다. 순회하는 도중에 rehashing이 일어나게 되면 두개의 hash table이 생성되는데 이 때 두개의 hash table 모두를 탐색하게 된다. 그럴 경우에 같은 값이 중복되어 반환될 수 있다.

테스트

개인 프로젝트를 진행하면서 레디스를 사용할 기회가 있어서 사용해 봤는데 같은 기능을 하는 코드를 SCAN과 MEMBER로 하는 두 개의 버전을 만들어 JUnit 테스트를 진행했다. 결과적으로 차이는 거의 없었다. 단, 고려해야할 사항은 Spring5 Junit4에서 싱글 스레드 기반 테스트였다는점인거 같다. 아무래도 멀티스레드로 테스트하면 MEMBER가 더 느리게 나타날꺼라고 생각된다. 물론 이것도.. MEMBER만 따로 테스트하고 SCAN만 따로 테스트하고 해야 하지만 현재는 싱글스레드니까 한번에 테스트를 진행했다. Spring5에서, Junit4에서 Parellel Test가 지원된다고 이야기는 들어봤는데.. 조금 더 확인해 봐야겠다.. 일단 테스트에 사용된 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {RootConfig.class, StompWebSocketConfig.class})
@Transactional
public class RedisTest {
@Autowired
private ChatRoomDaoRedis chatRoomDaoRedis;

@Test
public void getChatRoomWithMembers() {
for(int i = 0; i < 10000; i++)
chatRoomDaoRedis.getChatRoomsWithMembers("미국");
}

@Test
public void getChatRoomWithCursor() {
for(int i = 0; i < 10000; i++)
chatRoomDaoRedis.getChatRooms("미국");
}

@Test
public void getChatRoomWithMembers2() {
for(int i = 0; i < 10000; i++)
chatRoomDaoRedis.getChatRoomsWithMembers("미국");
}

@Test
public void getChatRoomWithCursor2() {
for(int i = 0; i < 10000; i++)
chatRoomDaoRedis.getChatRooms("미국");
}
}

같은 메소드를 왜 2번 수행하냐면은 처음 메소드가 수행될 때 클래스를 초기화 하는 시간이 측정 시간에 포함되기 때문이였다. 테스트에 사용되는 ChatRoomDaoRedis는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public List<ChatRoom> getChatRoomsWithMembers(String roomName) {
Set<String> keys = getChatRoomNameMatches(roomName);
LinkedList<ChatRoom> chatRooms = new LinkedList<>();
for(String key : keys)
chatRooms.addAll(getChatSpecificChatRoom(key));
return chatRooms;
}

private List<ChatRoom> getChatSpecificChatRoom(String roomName) {
return setOperations.members(CHATROOM + roomName).stream()
.map(roomId -> new ChatRoom(roomName, roomId))
.collect(Collectors.toList());
}

public List<ChatRoom> getChatRooms(String roomName) {
Set<String> keys = getChatRoomNameMatches(roomName);
List<ChatRoom> chatRooms = new LinkedList<>();
for(String key : keys)
chatRooms.addAll(getChatSpecificChatRoomCursor(key));
return chatRooms;
}

private Set<ChatRoom> getChatSpecificChatRoomCursor(String roomName) {
Cursor<String> cursor = setOperations.scan(CHATROOM + roomName, ScanOptions.scanOptions().match("*").build());
Set<ChatRoom> chatRoomList = new HashSet<>();
while(cursor.hasNext()) {
chatRoomList.add(new ChatRoom(roomName, cursor.next()));
}
return chatRoomList;
}

@SuppressWarnings("unchecked")
private Set<String> getChatRoomNameMatches(String roomName) {
return (Set<String>) redisTemplate.execute(new RedisCallback<Set<String>>() {
@Override
public Set<String> doInRedis(RedisConnection redisConnection) {
Set<String> keysTmp = new HashSet<>();
Cursor<byte[]> cursor = redisConnection.sScan(CHATS_BYTE, ScanOptions.scanOptions().match("*" + roomName + "*").build());
while(cursor.hasNext()) {
StringBuffer stringBuffer = new StringBuffer(new String(cursor.next(), StandardCharsets.UTF_8));
stringBuffer.deleteCharAt(0);
stringBuffer.deleteCharAt(stringBuffer.length() - 1);
keysTmp.add(stringBuffer.toString());
}
return keysTmp;
}
});
}

결과는 다음과 같다.

Result

참조

https://tech.kakao.com/2016/03/11/redis-scan/
https://redis.io/commands/scan
https://redis.io/commands/smembers
https://www.baeldung.com/maven-junit-parallel-tests
https://www.baeldung.com/spring-5-concurrent-tests

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/28/Redis/SCAN/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.