본문 바로가기

Language & Framework/삽질기록

삽질기록(23) Redis SortedSet으로 랭킹 기능 구현하기

 

이전 프로젝트였던 모락모락에서는 DB로 회원 랭킹을 관리했었는데, 해당 프로젝트는 조인해야할 테이블이 너무 많아서 쿼리 최적화로 상당히 애를 먹었던 기억이 있다.

물론 그건 내 쿼리 능력이 부족했기 때문이지만.. 아무튼 이번에는 Redis의 Collection 중 SortedSets을 이용해서 랭킹 기능을 구현하려고 한다.

 

Redis를 사용해보는 것 자체는 처음이 아니지만 SortedSets은 처음 사용해본다.

레퍼런스를 빠르게 대충 훑어봤는데 내가 원하는 정도의 기능은 별 거 없이 단순하게 작동하는 것 같다.

스프링에서 제공하는 메서드가 뭐가 있는지는 몰라서 나도 지금 실시간으로 소스코드 보면서 해볼 생각이다.

 

혹시나 보고 따라할 사람 있으면 일단 아래의 RedisConfig를 먼저 작성하도록 하자.

Lettuce가 뭐고 KeySerializer가 뭐고 이런 건 다른 곳 알아보삼

 

 

이제 레디스 스토어를 구현해야 하는데

의존성 역전의 원칙(Dependency Inversion Principal)에 의해 상위 모듈은 하위 모듈에 의존하면 안된다.

따라서 인터페이스를 만들어 도메인계층에 구현해주도록 하자.

 

 

 

이걸로 충분할지 나도 모른다

왜냐면 글 쓰면서 코드를 치고 있기 때문이다

아마 이렇게 하면 되지 않을까?

 

내가 필요한 랭크는 그냥 1위부터 10위까지로 고정이긴 한데, 정말 혹시나 모를 상황에 대비한 유연성을 위해 range로 설정하기로 했다.

 

 

 

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
49
50
51
52
@Slf4j
@Repository
@RequiredArgsConstructor
public class MeetingRankingStoreImpl<T> implements MeetingRankingStore<T> {
    private final RedisTemplate<StringString> redisTemplate;
    private final ObjectMapper objectMapper;
 
    private ZSetOperations<StringString> zSetOperations;
    private final String KEY = "RANKING";
 
    @PostConstruct
    public void init() {
        zSetOperations = redisTemplate.opsForZSet();
    }
 
    @Override
    public List<T> getRanks(String key, int startIdx, int endIdx) {
        Set<String> jsonSet = zSetOperations.reverseRange(KEY, startIdx, endIdx);
        return convertToValues(jsonSet);
    }
 
    @Override
    public void updateRank(String key, T rankDto) {
        String json = convertToJson(rankDto);
 
        zSetOperations.addIfAbsent(KEY, json, 0);
        zSetOperations.incrementScore(KEY, json, 1);
    }
 
    private String convertToJson(T rankDto) {
        try {
            return objectMapper.writeValueAsString(rankDto);
        } catch (Exception exception) {
            log.error("Redis IOException = ", exception);
            throw new UnableToProcessException(UNABLE_TO_PROCESS);
        }
    }
 
    private List<T> convertToValues(Set<String> jsonSet) {
        return jsonSet.stream()
                .map(e -> {
                    T value = null;
                    try {
                        value = objectMapper.readValue(e, (Class<T>) Object.class);
                    } catch (Exception exception) {
                        log.error("Redis IOException = ", exception);
                        throw new UnableToProcessException(UNABLE_TO_PROCESS);
                    }
                    return value;
                }).collect(Collectors.toList());
    }
}
cs
 

음.. 막상 해보니까 예외처리 때문에 생각보다 길게 나온다.

그리고 Redis에서 데이터 불러오는 것도 따로 예외처리를 해줘야하는지 고민이다.

 

원래는 ObjectMapper는 ObjectMapper대로 IOException 처리해서 별도 예외 던져주고, Redis는 Redis대로 Exception 캐치해서 던져줬었는데, 막상 돌려보니 연결에 문제가 있으면 예외가 터지는 부분이 저기가 아니라서..

 

열심히 작성하긴 했는데 정상적으로 동작할지는 모른다.

그러니까 이제 테스트 코드 쓰면 된다.

 

 

 

응 ~ 실패야 ~

사실 나도 코드 작성하면서 찜찜한 부분이 있긴 있었다.

 

 

 

 

바로 이 부분인데, 이전에 레디스 코드 작성할 때는 해당 파라미터에 Class<T> type을 주도록 했었다.

근데 매번 굳이 그렇게 할 필요가 있나? 싶어서 내 마음대로 코드를 짜봤는데.. 아니나 다를까 안되는군.

 

뭔가 방법이 있지 않을까 싶어서 3시간동안 서치하고 소스코드 읽어봐도 모르겠어서 현실에 순응하기로 했다..

이 방법대로 해서 쓰려면 결국 외부에서 후처리해주는 수밖에 없는데, 그럴 바에 그냥 파라미터 하나 더 넣는 게 낫다..

 

진짜 나중에 취업해서 개인 시간 좀 생기면 Jackson을 낱낱이 뜯어보고 싶다.. 얘는 정말 어쩔 때는 왜 되는지 모르겠고 어쩔 때는 왜 안되는지 모르겠음.. 시간이 없는게 한이다 ^^..

 

아무튼 결국 현실에 타협해서 해당 부분을

 

 

위와 같이 바꾸고 테스트는 모두 통과했다. 

 

참고로 테스트를 위해 @BeforeEach 돌려줄 DeleteAll() 메서드를 하나 만들어줬다.

근데 테스트만을 위한 메서드를 만드는 건 안티패턴이라고 한다.

ㅜㅠ.. 그치만 이 정도는 어쩔 수 없는 거 아닐까..? 

 

이제 여기까지 완성했다면 나머지 컨트롤러, 서비스 계충 구현하고

각각 단위 테스트하고 통합 테스트하면 끝이다.

 

 

 

생각할 부분들

1. 이번 글에서는 TTL을 설정하지 않았으나, 레디스는 인메모리 DB 특성상 용량이 작을 뿐더러 싱글쓰레드라 Collection에 많은 데이터가 저장되면 성능이 저하된다. TTL을 설정해야 한다.

2. Redis의 데이터는 이러니 저러니해도 휘발성이므로 데이터 유실의 우려가 있다.

만약 랭킹 정보를 꼭 간직하고 싶다면 wirte back 방식으로 DB에 저장하는 것이 현명하다.