spring Boot 프로젝트에서 조회수 기능을 구현하려고 합니다.
조회수는 다양한 방법으로 구현할 수 있어요. 조회때 마다 직접 DB를 +1 해줘도 되고, 쿠키나 세션을 사용해도 되고 캐시를 사용하는 방법도 있습니다.
오늘은 먼저 캐시를 통해 조회수를 구현하고 중복 체크를 하는 방법을 추가해보도록 하겠습니다.
조회가 발생할 때 마다 캐시에 게시글 아이디와 해당 게시글의 조회수를 기록하고 일정 주기마다 DB에 동기화시켜주는 방식으로 진행할 예정입니다.
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분으로 변경해줍니다.
동기화 코드는 마지막에 같이 소개하겠습니다.
조회를 만들고 조회수 중복 처리를 하려고 하니 여러 문제가 생겼습니다.
hash 구조로 모든 게시글과 유저 아이디를 저장하면 너무 많은 공간을 차지하게 되며,
set 구조로 저장하면 명확하지 않은 중복 처리 시간 기준이 만들어집니다. 즉 한 개의 게시글에 한 개의 만료시간만 지정할 수 있으니 조회를 한 뒤 1분뒤에 만료가 되고 또 다시 조회를 했을 때 조회수가 증가할 수도 있는 경우가 생깁니다.
물론 그렇게 크게 문제가 된다고 생각하지는 않습니다.
그래서 sorted set 구조를 통해 각각의 시간을 저장해주고 만료시간이 지나면 삭제하려고 했으나 이 방법 또한 직접 만료시간이 지난 기록을 삭제해줘야 해서 불편함이 많습니다. 이는 나중에 최근 방문한 페이지를 구현할 때 사용할 수 있을거라 생각합니다.
결국 제 기준에서 가장 티가 안 나고 리스크가 적다고 생각한 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); } }
그런데 기왕 중복체크를 하는 김에 게시글 id를 key로 저장하는 것 보다 사용자 아이디를 key로 설정하면 이후에 더 활용도가 높을 것 같아 수정해보도록 하겠습니다.
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에는 방문했던 게시글 번호가 저장되고 게시글 별로 조회수를 저장해줄 수 있습니다.
@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); } } } }
캐시를 사용하게 되면 어쩔 수 없이 약간의 실시간성은 포기해야 하는 상황이 발생합니다.
위 프로젝트에서도 조회수를 캐시로 관리하지만 동기화 주기가 3분이기 때문에 최대 3분의 딜레이가 생기게 됩니다. 하지만 그보다 얻는 성능 향상이 더 프로젝트에 도움이 된다고 생각합니다.
어느정도로 동기화 주기를 맞출 지, 어떤 캐싱 전략을 사용할 지 상황에 따라 유연한 대처가 필요합니다.