[spring] redis를 활용한 조회수 구현

김동욱·2023년 10월 22일
1

Spring

목록 보기
3/3
post-thumbnail

1. 들어가기 전

spring Boot 프로젝트에서 조회수 기능을 구현하려고 합니다.
조회수는 다양한 방법으로 구현할 수 있어요. 조회때 마다 직접 DB를 +1 해줘도 되고, 쿠키나 세션을 사용해도 되고 캐시를 사용하는 방법도 있습니다.

오늘은 먼저 캐시를 통해 조회수를 구현하고 중복 체크를 하는 방법을 추가해보도록 하겠습니다.

2. 조회수 구현

조회가 발생할 때 마다 캐시에 게시글 아이디와 해당 게시글의 조회수를 기록하고 일정 주기마다 DB에 동기화시켜주는 방식으로 진행할 예정입니다.

build.gradle 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
private final StringRedisTemplate stringRedisTemplate;

stringRedisTemplate는 Redis와 상호작용하기 위한 템플릿 클래스로 문자열에 특화되어 있지만 set이나 Hash도 사용가능합니다.

조회수 증가 메서드

public void increaseViews(Post post){
String key = "post:view:"+post.getPostId;
Boolean exist = stringRedisTemplate.opsForValue().setIfAbsent(key,post.getViews()+1,4L,TimeUnit.MINUTES);
if(Boolean.FALSE.equals(exist)){
stringRedisTemplate.opsForValue().increase(key);
stringRedisTemplate.expire(key,4L,TimeUnit.MINITES);
	}
}

redisUtil 컴포넌트를 생성하여 사용해도 좋지만 오늘은 직접 StringRedisTemplate을 사용했습니다.

첫 줄부터 설명하자면,
1. post:view:postId 형식으로 key 값을 설정합니다.
2. 만약 키가 존재하지 않는다면(조회된 적이 없다면) 해당 키와 조회수+1 값을 넣고 만료시간을 4분으로 설정해줍니다.
(나중에 스케줄러를 통해 매 3분마다 캐시와 DB를 동기화해 줄 것이라 4분으로 지정했습니다.)
3. 만약 false라면 조회된 적이 있다는 의미니까 캐시에 올라가있는 데이터가 있다는 의미입니다.
4. 해당 게시글의 조회수를 1 증가해주고, 만료시간도 4분으로 변경해줍니다.

동기화 코드는 마지막에 같이 소개하겠습니다.

3. 고려할만한 것

조회를 만들고 조회수 중복 처리를 하려고 하니 여러 문제가 생겼습니다.

hash 구조로 모든 게시글과 유저 아이디를 저장하면 너무 많은 공간을 차지하게 되며,

set 구조로 저장하면 명확하지 않은 중복 처리 시간 기준이 만들어집니다. 즉 한 개의 게시글에 한 개의 만료시간만 지정할 수 있으니 조회를 한 뒤 1분뒤에 만료가 되고 또 다시 조회를 했을 때 조회수가 증가할 수도 있는 경우가 생깁니다.
물론 그렇게 크게 문제가 된다고 생각하지는 않습니다.

그래서 sorted set 구조를 통해 각각의 시간을 저장해주고 만료시간이 지나면 삭제하려고 했으나 이 방법 또한 직접 만료시간이 지난 기록을 삭제해줘야 해서 불편함이 많습니다. 이는 나중에 최근 방문한 페이지를 구현할 때 사용할 수 있을거라 생각합니다.

결국 제 기준에서 가장 티가 안 나고 리스크가 적다고 생각한 set 방식으로 중복을 체크하려고 합니다.

4. set을 사용한 조회수 중복

public void increaseViews(Post post, Long userId) {
        String user = String.valueOf(userId);
        String key = "postId:" + Post.getPostId();
        String totalViewsKey = "post:views:" + Post.getPostId();
    
        if (!stringRedisTemplate.hasKey(key)) {
            stringRedisTemplate.opsForSet().add(key, user);
            stringRedisTemplate.expire(key, 24, TimeUnit.HOURS);
            stringRedisTemplate.opsForValue().set(totalViewsKey, String.valueOf(planner.getViews() + 1), 4L, TimeUnit.MINUTES);
         
        } else if (!stringRedisTemplate.opsForSet().isMember(key, user)) {
            stringRedisTemplate.opsForSet().add(key, user);
            stringRedisTemplate.opsForValue().increment(totalViewsKey);
            stringRedisTemplate.expire(totalViewsKey,4L,TimeUnit.MINUTES);
        }
    }
  1. 총 조회수를 저장하는 건 기존과 비슷하면서 중복 처리를 위해 set을 추가했습니다.
  2. 게시글 아이디를 기준으로 set 데이터를 생성하여 조회 시 userId를 추가해주면서 중복을 체크해줬습니다.

그런데 기왕 중복체크를 하는 김에 게시글 id를 key로 저장하는 것 보다 사용자 아이디를 key로 설정하면 이후에 더 활용도가 높을 것 같아 수정해보도록 하겠습니다.

5. 더 다양한 활용을 위해

    public void increaseViewsUser(Planner planner, Long plannerId, Long loginUserId) {
        String key = "user:" + loginUserId;
        String totalView = "planner:views:" + plannerId;
        Long views = planner.getViews();
        long currentTime = System.currentTimeMillis();
        if (Boolean.FALSE.equals(stringRedisTemplate.hasKey(key))) {
            stringRedisTemplate.opsForSet().add(key, String.valueOf(plannerId));
            stringRedisTemplate.expire(key, 24, TimeUnit.HOURS);
            stringRedisTemplate.opsForValue().set(totalView, String.valueOf(views + 1), 4L, TimeUnit.MINUTES);

        } else if (Boolean.FALSE.equals(stringRedisTemplate.opsForSet().isMember(key, String.valueOf(plannerId)))) {
            stringRedisTemplate.opsForSet().add(key, String.valueOf(plannerId));

            if (Boolean.FALSE.equals(stringRedisTemplate.hasKey(totalView))) {
                stringRedisTemplate.opsForValue().set(totalView, String.valueOf(views));
            }
            stringRedisTemplate.opsForValue().increment(totalView);
            stringRedisTemplate.expire(totalView, 4L, TimeUnit.MINUTES);
        }
    }
  • 사용자 아이디를 key로 설정하고 기존 코드에 고려되지 않았던
    "Boolean.FALSE.equals(stringRedisTemplate.hasKey(totalView)"를 추가했습니다.

  • 이와 같이 사용하면 user의 set에는 방문했던 게시글 번호가 저장되고 게시글 별로 조회수를 저장해줄 수 있습니다.

6. DB 동기화

  @Transactional
    @Scheduled(cron = "0 0/3 * * * *")
    public void updateDBFromRedis() {

        ScanOptions options = ScanOptions.scanOptions()
                .match("post:views:*")
                .count(10)
                .build();
        Cursor<byte[]> cursor = stringRedisTemplate.executeWithStickyConnection(
                connection -> connection.scan(options)
        );
        if (cursor != null) {
            while (cursor.hasNext()) {
                String key = new String(cursor.next());
                Long postId = Long.parseLong(key.split(":")[2]);
                String view = stringRedisTemplate.opsForValue().get(key);
                if (view != null) {
                    postRepository.updateViews(Long.valueOf(view), postId);
                }
            }
        }
    }
  1. @Scheduled 어노테이션으로 수행 주기를 설정해줍니다.
  2. key를 호출하는 방법에는 한번에 모든 키를 불러오는 stringRedistemplete.keys(); 메서드가 존재하지만 이는 한번에 모든 키 값을 불러오기 때문에 좋지 않습니다. 그래서 scan을 통해 일정 갯수만큼 호출하여 처리해야 합니다.
  3. Redis에 연결해서 scan을 해주고
  4. key값을 문자열로 가져와 ":"로 구분된 3번째 postId 부분을 가져와 postId 에 저장해줍니다.
  5. 마지막으로 키를 통해 값을 가져와 저장소에 저장해줍니다.

7. 정리

캐시를 사용하게 되면 어쩔 수 없이 약간의 실시간성은 포기해야 하는 상황이 발생합니다.
위 프로젝트에서도 조회수를 캐시로 관리하지만 동기화 주기가 3분이기 때문에 최대 3분의 딜레이가 생기게 됩니다. 하지만 그보다 얻는 성능 향상이 더 프로젝트에 도움이 된다고 생각합니다.

어느정도로 동기화 주기를 맞출 지, 어떤 캐싱 전략을 사용할 지 상황에 따라 유연한 대처가 필요합니다.


Redis 공식 문서

profile
안녕하세요. 공부해요

0개의 댓글