개인 프로젝트를 진행하면서, 영상 숏폼 게시글의 실시간 인기 게시글 상위 5개를 조회하는 것에 대해 만들어야 하는 부분이 있었다. (근데 실시간에 대해선 더욱 생각을 해보아야할 것 같다. 이번 게시글을 실시간에 대한 내용은 배제했다.)
이에 처음에 구상한 내용은 레디스를 사용하지 않고, 디비에서 정렬을 하여, 상위 5개를 가져오는 것을 생각을 하였다.
그런데, 이렇게 진행할 경우, 많은 사용자가 인기게시글을 조회하면 할때마다, 쿼리를 날리면서 정렬을 하겠는가? 이는 잘못됐다고 생각했다.
-> 그래서 redis를 사용하자고 생각을 했다.
일단 redis를 사용하지 않았을때의 현상을 먼저 확인하자.
일단 조회수가 상위 5개인 것을 조회하는 것을 하는 거에 앞서, 해당 게시글을 조회하였을 때에 대해서 작성을 한다. 일단 조회수 중복에 대해서는 해당 게시글에서는 배제하고 진행을 하겠다.



이런 식으로, 해당 게시글을 읽었을 때, post_id에 해당하는 post를 가져와서 조회수를 하나 올려서 update시키는 형태로 만들었다.
그랬을 때, 동시에 500명의 사용자가 해당 게시글을 조회했을 때, 어떠한 상황이 나오는지 확인해보자.
테스트는 jemter를 활용하였음.

위 사진에서는 표본수 500을 보면 평균시간 13에 처리량이 499정도가 나온 것을 확인하였다.
그랬을때, 데이터베이스에는 500명이 보았다면, view(조회수 칼럼)이 500이 되어있어야한다.
그러나 확인해본 결과, view가 500이 되어있는 게 아닌,186이 되어있는 것을 확인 할 수 있었다. 대체 왜 그런 것일까? 이는 바로 동시성 문제 때문이다. 동시에 사용자가 해당 게시글을 조회를 하여 100인 조회수를 동시에 조회해서 +1을 하고 업데이트를 하였을때 102이 되어야하는데, 101이 되는 데이터의 정합성이 깨진 것이다.
또한,

이 보다 훨씬 많이 select, update 쿼리가 날라간 것을 볼 수있다.
문제점
- 조회할때마다, 무수히 많은 조회,업데이트 쿼리 발생으로 인한 성능 하락
- 동시에 조회로인한 동시성문제로 인한 데이터 정합성문제
일단, 조회할떄마다 무수한 쿼리로 인한 성능 하락을 처리하기 위해 캐시기능 중 글로벌 캐시인 Redis를 사용할 예정이다.
이는 해당 게시글을 조회할 시, redis를 먼저 확인해보고, 해당하는 게시글이 없다면 DB에서 찾아보고 디비에서 해당 값을 가져와서 조회수를 한개 올리고 redis에 add할 것임을 생각했다. redis에 넣을 때는 sorted set 자료구조형태로 넣을 생각이다.
<redis 기본 설정>


해당 코드는 sorted Set자료구조의 대한 설정이다.
< Controller>

< Service>

위 서비스와 같이, redis에서 해당 게시글 찾아오고 없다면 디비에서 찾아오고 redis에 조회수 +1하게 끔 만들었고, redis에 있을 시 redis에 score를 +1 시키게 만들었습니다.
redis는 싱글 쓰레드라 동시성 문제도 잡힐 거라는 생각을 했습니다. 결과를 보시면

캐시를 사용 안 했을때와, 처리량은 그렇게 차이가 나지 않지만, 처리시간은 평균 13->3로 바뀌었습니다,. 아마 간단한 쿼리 연산이라서 그리 차이가 안 난게 아닌가 싶긴한데.. 아니면 댓 부탁드립니당..
500을 조회했는데 레디스이니 싱글스레드이니 원자성을 보장하니 조회수가 500올라갈 거라 생각을 했다.

레디스에 조호수를 저장하였으니, 확인을 해보았다. 근데 500이 아니라 491..?? 왜 그런걸까 생각을 해보았다. 그래서 로그를 한번 보았다.

그런데, 게시글의 값이 redis에 없을때, 디비에서 가져올때 select 쿼리가 한개 나가는 줄 알았는데, 여러개가 나간 것이다. 그 이유는 스프링부트가 멀티쓰레드 환경이므로 동시에 여러번 디비에서 해당 게시글을 조회해서 그런 것이다. 그렇기에 500번이 아닌 491의 조회수가 찍히는 것을 확인할 수 있었다.
Redis에 접근할 수 있는 다양한 클라이언트들이 존재한다. 대표적으로 Jedis, Lettuce, Redisson등이 있다. 나는 그 중에서도 Redisson을 사용해서 분산락을 구현해보도록 하겠다.
Redisson은 비교적 합리적인 방식으로 Lock 획득 재시도 기능이 구현되어 있다.
Lettuce는 '스핀락'이라고 불리는 일종의 폴링 기법을 활용해서 Lock 획득을 재시도하는 한편 Redisson은 Redis의 Pub/sub 기능을 사용해서 Lock 획득을 재시도한다.
즉, Lock획득에 실패하면, Redisson은 특정 채널을 구독하고, Lock이 다시 획득할 수 있는 상태가 됐다는 이벤트를 받았을 때, 다시 Lock획득을 시도하는 것이다. 이는 Lock이 획득될 때까지 계속 Lock획득 요청하는 Lettuce보다 효율적이고, Redis서버에도 부하를 덜 주는 방법이라고 볼 수 있다.
자 이제 Redisson으로 분산락을 구현해보도록 하자.
<설정>
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
< RedisConfig>
//redsson 설정 분산락
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
return Redisson.create(config);
}
추가
< DistributedLockAspect>
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(distributedLock)")
public Object lockAround(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
RLock lock = redissonClient.getLock(distributedLock.value());
try {
boolean isLocked = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!isLocked) {
System.out.println("lock 획득 실패");
return false;
}
return joinPoint.proceed();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
< DistributedLock>
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributedLock {
String value(); // Lock의 이름 (고유값)
/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 락을 기다리는 시간 (default - 5s)
* 락 획득을 위해 waitTime 만큼 대기한다
*/
long waitTime() default 5L;
/**
* 락 임대 시간 (default - 3s)
* 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
*/
long leaseTime() default 3L;
}
이렇게 추가해준다.
그리고
< Service>

- Redis 캐싱 및 조회: Redis에 조회수를 캐싱해 두면, 대부분의 경우 DB 접근 없이 Redis에서 빠르게 조회수 관리를 할 수 있습니다. 이는 성능 향상에 매우 유리합니다.
- 분산 락 사용: Redis에 조회수가 없는 경우에는 분산 락을 사용하여, 여러 트랜잭션이 동시에 DB에서 조회수를 가져오고 이를 Redis에 저장하는 것을 방지합니다. 락이 걸린 상태에서만 DB 접근이 이루어지므로, 조회수 관리의 일관성을 유지할 수 있습니다.
- Transactional 보장: handleViewWithLock 메서드는 @Transactional 애너테이션이 적용되어 있으므로, 이 메서드 내에서 발생하는 DB 작업은 트랜잭션이 보장됩니다. 트랜잭션이 종료되면, 변경된 조회수가 확실히 반영됩니다.
이렇게 하였을때, 결과를 보자

평균시간은 2로 매우 줄었음을 보았다.
그랬을때, 그럼 redis에는 500이 정확히 조회수가 들어갔을까?

제대로 들어갔음을 확인했다.
다음은 실시간 및 상위 top을 가져오는 것을 작성하겠다.

서비스 로직에서, @DistributedLock(value = "view:#viewId")를 postView2()메소드 전체에 락을 걸어놨는데, 우리가 락을 걸어야하는 곳은 handleViewWithLock() 디비 접근하는 부분 뿐이다. 그럼 @DistributedLock(value = "view:#viewId")를 handleViewWithLock() 메소드에 걸면 되는 거 아니야? 라는 생각을 할 수 있다. 허나 이러한 예외가 발생한다.
<예상 시나리오>
- 동시 호출:
스레드 A와 스레드 B가 동시에 postView2 메서드를 호출합니다.
- Redis에서 조회수 없음:
두 스레드 모두 Redis에서 해당 게시글의 조회수를 찾지 못하고, check == null이 됩니다.
- 분산 락 획득 시도:
스레드 A가 먼저 handleViewWithLock 메서드를 호출하고 분산 락을 획득합니다.스레드 B는 락을 기다립니다.
- 스레드 A 작업 완료:
스레드 A는 DB에서 조회수를 가져와 Redis에 저장하고, 락을 해제합니다.
- 스레드 B의 작업 진행:
스레드 A가 락을 해제한 후, 스레드 B가 락을 획득하고 handleViewWithLock 메서드를 실행합니다.
스레드 B는 이미 스레드 A가 처리한 작업을 다시 수행합니다.
이렇게 동시성 문제가 결국엔 발생하게 된다. 근데 위에 사진처럼 저렇게 PostView2() 메소드에 전체 락을 걸어버리면, 락을 걸 필요도 없는 레디스에 값이 존재할때도 락이 걸리니, 쓸때없는 지연이 발생하게 된다. 이는 어떻게 수정해야할지 고민을 해봐야할 문제이다.
참고문헌
참고
https://helloworld.kurly.com/blog/distributed-redisson-lock/