스프링 Redis로 캐시 처리하기 조회 수, 방문자 수 업데이트
지난 글에서 우리 프로젝트에 개별 상품 조회를 redis로 캐싱 처리했었습니다. 캐시 삭제 주기를 6시간으로 많은 시간을 두고 했었는데 이렇게 설정하게 되면 그 사이에 조회 수가 올라도 이미 cacheable로 캐시에 반영이 되어 있어서 아무리 상품 조회를 많이 해도 조회수가 늘지 않는다는 것입니다.
선택지는 두 가지가 있었습니다.
첫 번째. 조회수 전용 엔티티를 만들어 따로 관리합니다. 상대적으로 적은 어트리뷰트를 가지면서 그에 따른 시간 이득이 있을 수 있으나 인메모리 기반 레디스에 비하면 속도가 현저히 늦을거라고 생각했습니다.
두 번째. redis에 조회수만 담당하는 캐시를 올리자.
본래는 product 엔티티를 전부 올려 그 때마다 데이터를 읽어 조회수 카운트를 올릴 생각이었습니다. 그러나 key 값만 productViewCnt::{productId} 같은 네이밍 방식을 사용하고 value에 viewCnt 값만 관리하게 되면 훨씬 속도적으로,비용적으로 이득일 것 같아 조회수만 관리하였습니다. redis에 올리게 되면 속도도 좋고, 엔티티를 늘리지 않아도 된다는 부담도 없었기에 두 번째 방법을 선택해 프로젝트에 적용 시키려 했습니다.
우선 5분마다 상품 조회의 캐시가 만료되게 설정하고 상품 조회가 올라오면 addViewCntToRedis 메소드를 호출하고 별 다른 어노테이션 없이 redisTemplate을 직접 관리해 set과 delete를 하게 하였습니다.
처음 생각은 cacheable을 이용해 캐시된 조회수를 조회하고 +1을 한 값을 @cacheput을 이용해 캐시 업데이트를 진행하려고 하였습니다. 그러나 cacheable을 사용해 캐시 실행을 건너 뛸 수 있고 cacheput을 실행을 강제해 서로 어긋나 계속해서 cacheable이 안되어 있는 데이터베이스 조회만 수행하였습니다. cacheable과 cacheput은 동시에 사용할 수 없었습니다.
방법을 바꾸어 template에 직접 조회하는 방식을 택하였습니다.
원하는 key 값을 설정해주고 redisTemplate.opsForValue()로 redis 캐시에 올라간 값들을 조회해주었습니다.
만약 캐싱 처리가 안되어 있다면 set을 통해서 데이터베이스로 조회한 값을 넣어주었습니다. 주의할 점은 Map<string,string> 이기 때문에 Long 타입을 변환해줘야 합니다.. 캐시 처리가 되어 있다면 valueOperations의 increment 메소드를 통해서 값을 조회수를 증가 시켜줍니다.
Service단 상단에 아래 코드로 입력해주세요
private final RedisTemplate redisTemplate;
조회수 구현 메소드입니다.
public void addViewCntToRedis(Long productId) {
String key = "productViewCnt::"+productId;
//hint 캐시에 값이 없으면 레포지토리에서 조회 있으면 값을 증가시킨다.
ValueOperations valueOperations = redisTemplate.opsForValue();
if(valueOperations.get(key)==null)
valueOperations.set(
key,
String.valueOf(productRepository.findProductViewCnt(productId)),
Duration.ofMinutes(5));
else
valueOperations.increment(key);
log.info("value:{}",valueOperations.get(key));
}
이 때 주의할 점이 redisTemplate에서 StringRedisSerializer를 설정해주어야 key 값에 \xac\xed\x00\x05t\x00\x11 같은 코드가 들어가지 않습니다. 아무것도 템플릿에 설정하지 않으면 JdkSerializationRedisSerializer 의 기본 값이 들어가게 된다고 합니다.
따라서 RedisConfig에 다음 코드를 추가해주세요.
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<byte[], byte[]> template = new RedisTemplate<>();
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setConnectionFactory(lettuceConnectionFactory());
return template;
}
Controller 단에서는 cacheable 처리한 메소드 전에 먼저 조회수 처리 메소드를 실행시켰습니다.
@GetMapping("/{productId}")
public ResponseEntity<SingleResult<ProductResponse>> findProductById(
@RequestHeader("Authorization") String accessToken,
@PathVariable Long productId) {
productService.addViewCntToRedis(productId);
return new ResponseEntity<>(responseService.getSingleResult(ProductResponse.toProductResponse
(productService.findProductById(productId))), HttpStatus.OK);
}
이제 정상적으로 캐시가 들어가게 될 것이고 ,, redis-cli 접속하셔서 keys *를 누르시면 기존 cacheable처리된 캐시 말고도 임의로 넣은 캐시가 보일 것입니다.
이제 조회를 할때마다 productViewCnt::{productId}의 값이 하나씩 늘어날 것입니다. 이제 조회수는 해결했으니 쌓인 캐시들을 읽어 데이터베이스에 반영해야 합니다. 3분마다 데이터베이스에 반영하도록 하기 위해 스프링 배치 스케쥴러를 이용했습니다.
메인에 @EnableScheduling을 추가해줘 스케쥴링을 할 수 있게 합니다.
@SpringBootApplication
@EnableJpaAuditing
@EnableCaching
@EnableScheduling
public class ProjectApplication {
public static void main(String[] args) {
SpringApplication.run(ProjectApplication.class, args);
}
@Bean
public RestTemplate getRestTemplate() {
return new RestTemplate();
}
}
단순히 아래 코드만으로 메소드를 설정 시간마다 수행할 수 있게 해줍니다.
@Scheduled(cron = "0 0/3 * * * ?")
왼쪽부터 초,분,시,날,달,요일,연도를 뜻하며 우리 프로젝트에는 3분마다 메소드 수행을 하도록 설정하였습니다.
redisTemplate.keys 메소드로 productViewCnt 패턴의 키들을 불러옵니다.
아까 예시에 따르면 "productViewCnt::2", "productViewCnt::4" 입니다. iterator로 키들을 하나씩 부르면서 addViewCntFromRedis라는 메소드로 업데이트를 수행하게 하였습니다. 이 후에 업데이트 된 데이터베이스 반영을 위해 관련 캐시들을 전부 삭제하도록 하였습니다. 이렇게 해야 조회할때 사용된 cacheable의 캐시도 삭제되면서 업데이트된 조회수를 새로 불러올 수 있기 때문입니다.
@Scheduled(cron = "0 0/3 * * * ?")
public void deleteViewCntCacheFromRedis() {
Set<String> redisKeys = redisTemplate.keys("productViewCnt*");
Iterator<String> it = redisKeys.iterator();
while (it.hasNext()) {
String data = it.next();
Long productId = Long.parseLong(data.split("::")[1]);
Long viewCnt = Long.parseLong((String) redisTemplate.opsForValue().get(data));
productRepository.addViewCntFromRedis(productId,viewCnt);
redisTemplate.delete(data);
redisTemplate.delete("product::"+productId);
}
}
QueryDsl Impl부분에 구현해두었습니다. 같은 id를 찾아 단순 update시키는 로직입니다.
@Override
public void addViewCntFromRedis(Long productId,Long addCnt) {
queryFactory
.update(product)
.set(product.productViewCnt,addCnt)
.where(product.productId.eq(productId))
.execute();
}
결론
조회수의 반영에는 3분정도 딜레이가 있다고 생각하시면 됩니다.

요청 Request : 100만 -> 480번
가령 하루에 100만번의 요청이 있다면 캐시 적용을 안 했을땐 100만번의 요청을 그대로 데이터베이스에서 받게 될 것입니다. 그러나 3분마다 캐시 리프레쉬를 하게 되면 20 * 24 = 480번의 요청만 데이터베이스에 하게 되는 것입니다. 이렇게 많은 이득이 있기에 하루에 많은 Request가 있는 대규모의 서비스 기업의 경우엔 캐시 서버가 필수적이라고 생각이 듭니다.
참고문헌
[spring] cache
캐시는 미래의 데이터 요청에 빠르게 응답하기 위한 데이터 저장의 한 방법이다. (wikipedia) 캐시의 활용은 다양한데, 동일한 데이터를 계속해서 활용할 경우 계산 작업을 중복해서 하지 않도록
devidea.tistory.com
Get Set value from Redis using RedisTemplate
I am able to retrieve values from Redis using Jedis: public static void main(String[] args) { Jedis jedis = new Jedis(HOST, PORT); jedis.connect(); Set<String> set = ...
stackoverflow.com
Spring 스케줄링 (@Scheduled)
안녕하세요. 오늘은 제가 프로젝트 진행 중에 스프링에서 스케줄링 하는 방법에 대해서 알게 되어서 기록을 남기고 제가 모를때 다시 참조하거나 혹시 저와 같이 모르는 분들에게 도움이 되고
toma0912.tistory.com
Redis를 사용한 View count, 방문자 수 관리하는 효과적인 방법은?
Python에 redis cache를 적용하면서 view count... 방문자가 올때 카운트 값을 1을 늘리기위해 데이터베이스를 업데이트하는 일이 너무 비효율적으로 보였다. 뭔가 방법이 없을까?만약 방문자의 일일 페
webisfree.com