쓰기 지연 전략
우리 프로젝트에서는 게시물이 "조회수"라는 필드를 갖고 있었다.
조회수는 게시물이 조회될 때마다 그 값이 1씩 증가한다.
초기에는 게시물이 조회 될 때마다 RDB에 해당 게시물의 조회수를 1 증가하는 Update 쿼리를 날려주는 방식을 사용하였다.
※ 조회수는 설계상 기본적으로 RDB에 저장되고 사용되었다.
[조회수 증가_JPA]
@Modifying(flushAutomatically = true)
@Query("update Board b set b.views = b.views + 1 where b.id = :boardId")
void updateViews(@Param("boardId") Long boardId);
실제로 위와 같은 메서드를 JPA로 만들고 게시물이 조회될 때마다 해당 메서드를 호출하여 게시물의 조회수를 증가시켰다.
위 방식을 사용하면 게시물을 조회할 때, 별다른 문제없이 게시물의 조회수가 증가한다.
하지만 이 방법에는 치명적인 단점이 있다.
그것은 매번 조회가 일어날 때마다 Update 쿼리가 발생한다는 것이다.
특히 "조회수"라는 것은 변경되는 빈도가 매우 잦은 항목이기 때문에 더욱 문제가 된다.
예를 들어 서비스에서 하루 평균 1만 건의 조회가 발생한다고 하자
그러면 서버에는 하루에 1만건의 Update 쿼리가 발생한다.
RDB에서 Update는 조회와 달리 비용이 크기 때문에 1만건의 Update 쿼리는 서버에 적지 않은 부담을 준다.
이때, 캐싱 전략 중 쓰기 지연 전략을 사용해서 위와 같은 문제를 해결할 수 있다.
1. 쓰기지연 전략
캐싱 전략에 쓰기 지연(Write-Back)이라는 전략이 있다.
쓰기 지연이란 쓰기 연산을 캐싱한 뒤 RDB에 벌크로 쓰기 연산을 하는 것을 뜻한다.
[Write-Back]
이를 프로젝트의 조회수에 적용하면 다음과 같은 형식이 된다.
[쓰기 지연 전략_Redis]
클라이언트가 게시물 조회 요청을 하면 조회수를 증가시켜서 RDB에 반영하는 것이 아니라 우선 Redis에 값을 저장시킨다.
조회수가 조회될 때는 RDB가 아닌 Redis에서 값을 조회한다.
※ 단, Redis에 조회하려는 조회수가 없을 경우 RDB에서 그 값을 가져온다
이후에 일정 주기마다 Redis에 저장된 조회수를 RDB에 한 번에 반영한다.
전체 구조를 간단히 요약한다면 단순히 조회수라는 필드를 RDB가 아닌 캐시 서버(Redis)에서 관리한다고 보면 되겠다.
Redis는 인메모리 방식의 DB로 RDB에 비해 조회, 수정에 대한 비용이 굉장히 적다.
따라서 RDB에 매번 Update 쿼리를 날리는 것보다 Redis에 조회수를 저장했다가 한 번에 RDB에 값을 반영하는 방식은
앞서 많은 양의 Update 쿼리로 발생하는 문제를 해결할 수 있다.
2. 쓰기 지연 전략 적용
본 프로젝트의 조회수에 쓰기 지연 전략을 적용할 경우 다음과 같은 부분을 구현해야 한다.
1. 게시물이 조회될 때 1 증가된 조회수를 Redis에 반영
2. 게시물이 삭제되면 Redis에서 해당 게시물의 조회수 삭제
3. 일정 주기마다 Redis의 조회수를 RDS에 반영
Spring에서는 CachManager를 통해 캐시에 관한 다양한 처리를 어노테이션으로 지원받을 수 있었다.
하지만 이 경우 응답에 대한 전체 내용을 캐싱하는 것이 아니라 특정 조회수 필드만을 Redis에 저장해야 하므로 CachManager의 어노테이션만으로 이를 처리하기에는 다소 무리가 있다.
이런 세밀한 처리는 Redis에 직접 접근해서 값을 처리해 주면 된다.
Redis에 대한 직접적인 접근은 RedisTemplate을 사용하면 된다.
2.1 게시물이 조회될 때 1 증가된 조회수를 Redis에 반영
게시물이 조회될 때 1 증가된 조회수를 Redis에 반영하고 응답될 게시물 정보에 증가된 조회수를 반영해주어야 한다.
[조회수 증가 반영_Redis]
public void upViewToRedis(Long boardId, BoardDetailsRes boardDetailsRes) {
String key = "boardView::" + boardId;
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue(); //[1]
String view = valueOperations.get(key); //[2]
if (view == null) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new BusinessLogicException(ExceptionCode.NOT_FOUND_BOARD));
valueOperations.set(key, String.valueOf(board.getViews() + 1)); //[3]
boardDetailsRes.setViews(board.getViews() + 1);
} else {
valueOperations.increment(key); //[4]
boardDetailsRes.setViews(Integer.parseInt(view) + 1);
}
}
우선적으로 Redis에서 조회된 게시물의 조회수에 대해 저장된 값이 있는지 확인한다.
Redis에 조회수가 저장된있지 않을 경우 RDB에서 조회수를 조회하고 값을 1 증가시켜 Redis에 저장, 응답(게시물)에 증가된 조회수를 반영한다.
Redis에 조회수가 저장되 있을 경우 Redis에 저장된 값에 1을 증가시키고 응답(게시물)에 증가된 조회수를 반영한다.
[1] : RedisTemplate의 opsForValue()를 사용해서 Redis에 저장된 값들에 접근할 수 있다.
opsForValue()의 반환값은 Redis에 저장된 Key와 Value의 타입을 지정해 주면 된다.
본 프로젝트에서는 둘 다 String으로 설정했기에 이에 맞춰 반환값의 타입을 설정해 주었다.
[2] : ValueOperations에 get을 사용하면 Key를 통해 Value를 조회할 수 있다.
[3] : ValueOperations에 set을 사용하면 Key와 Value를 저장할 수 있다.
[4] : ValueOperations에 increment를 사용하면 Key의 Value에 1을 증가시킨다.(Value 타입이 String이어도 숫자 형식이면 그냥 증가시켜 준다 개꿀)
[게시물 조회_Controller]
@GetMapping("/{boardId}")
public ResponseEntity<BoardDetailsRes> boardDetails(@PathVariable Long boardId) {
BoardDetailsRes response = boardService.findBoard(boardId);
boardService.upViewToRedis(boardId, response);
return new ResponseEntity<>(response, HttpStatus.OK);
}
Controller 계층에서 게시물을 조회하고 Redis에 조회수를 업데이트하고 응답되는 게시물에 이를 반영하는 메서드를 적용한다.
실제로 게시글을 조회하는 요청이 오면 조회수가 증가되고 증가된 조회수가 응답으로 잘 반환되는 것을 확인할 수 있다.
[게시글 조회]
RDS에 쿼리문 하나 날리지 않고 조회수를 증가시켰다 신기하지 않은가?
2.2 게시물이 삭제되면 Redis에서 해당 게시물의 조회수 삭제
게시물이 삭제되면 Redis에 해당 게시물의 조회수 부분을 삭제해야 한다.
이는 Redis에 있는 특정값을 단순 삭제하는 것으로 CacheManager의 @CacheEvict를 사용해서 간단하게 처리할 수 있다.
[게시물 목록 조회]
@Transactional
@CacheEvict(key = "#boardId", value = {"findBoard", "boardView"})
public void removeBoard(Long loginAccountId, Long boardId) {
.
.
.
}
게시물을 삭제하는 메서드에 @CacheEvict를 사용해서 게시물의 조회수를 삭제 처리하였다.
3.3 일정 주기마다 Redis의 조회수를 RDS에 반영
만약 Redis에 저장된 정보들을 RDB에 반영하지 않는다면, Redis에 문제가 생길 경우 조회수 정보를 RDB에 영영 반영할 수 없는
상황이 발생할 수 있다. 조회수는 서비스 이용자에게 제공되는 중요한 지표이므로 정보가 삭제되는 상황은 바람직하지 않다.
따라서 이를 방지하기 위해서라도 Redis에 쌓인 쓰기(조회수 정보)들을 일정 주기마다 꾸준히 RDB에 반영해주어야 한다.
우선 RDB에 Redis의 조회수 정보들을 반영하는 메서드를 만든다.
[updateViewToMySql]
private final RedisTemplate<String, String> redisTemplate;
private final BoardRepository boardRepository;
@Transactional
public void updateViewToMySql() {
Set<String> keys = redisTemplate.keys("boardView*");
if (keys == null) {
return;
}
for (String key : keys) {
long boardId = Long.parseLong(key.split("::")[1]);
int views = Integer.parseInt(redisTemplate.opsForValue().get(key));
boardRepository.updateViews(boardId, views);
}
}
Redis에서 모든 조회수에 대한 값들을 조회해서 RDB에 Update 해주면 된다.
Redis의 key값에 게시물의 id가 있기 때문에 이를 통해 조회수를 반영할 게시물을 찾으면 된다.
일정 주기마다 Redis의 조회수 정보를 RDB에 반영하는 것은 Spring의 @Scheduled를 사용하였다.
[sceduleCache]
@Scheduled(cron = "0 0 5 * * ?")
public void scheduleCache() {
cacheProcessor.updateViewToMySql();
cacheProcessor.flushRedis();
}
매 일 오전 5시마다 RDB에 데이터를 반영한다.
쓰기 작업을 Redis에 모아두었다 RDB에 반영할 때 결국 Update 쿼리가 발생하기 때문에 서버에 부담이 발생할 수 있다.
따라서 서비스 사용자가 제일 적은 시간대에 해당 작업을 실행하도록 한다.
3. 문제점
원래 우리가 제공하는 서비스에서는 게시물을 조회수로 정렬해 주는 기능이 존재했었다.
RDB에서 조회수 항목을 통해 게시물들을 정렬했을 때는 문제없었지만 Redis로 조회수를 분리시킨 이후에 문제가 발생했다.
문제의 원인은 이러했다.
정렬은 RDB의 조회수를 통해서 하게 되는데 실제 조회수 값들은 Redis에 저장되어 있기 때문에 정렬이 맞지 않는다는 것이었다.
예를 들어 조회수가 각각 10, 11인 게시물 A, B가 있을 때 조회수로 정렬하면 B, A순으로 게시물이 조회되어야 한다.
하지만 사용자가 게시물을 조회함에 따라 15, 11로 A, B의 조회수가 변했다고 하자.
이 경우 RDB에서 A, B의 조회수는 10, 11 그대로이기 때문에 A, B가 아닌 B, A순으로 조회가 된다.
조회수라는 항목만 RDB에서 분리되어 처리되기에 발생한 문제였다.
이를 해결하기 위한 방법을 찾아봤지만 분리된 조회수를 게시물에 적용시켜 정렬하는 방법을 쉽게 찾을 수 없었다.
결국 서비스에서 조회수로 정렬하는 부분을 제거하였다.
쓰기 지연을 적용할 때는 적용될 자원이 어떻게 사용될지 고려하고 향후 서비스에 영향이 없는지를 꼼꼼히 확인해야 할 것 같다.
'프로젝트 > Tripagram' 카테고리의 다른 글
Reids를 사용한 Cache 전략 (0) | 2023.02.06 |
---|---|
[설정] 초기 개발환경 구성 (0) | 2023.01.20 |
Tripagram 회고 (0) | 2023.01.02 |
댓글