내 프로젝트에서 좋아요 기능에 대해서 겪은 이슈와 여러 과정을 통해 알게된 해결방법과 최종적으로 Redis 를 활용한 해결방법에 대해서 포스팅 해보겠다 👊
프로젝트에 리뷰 기능이 존재하고, 리뷰에 좋아요를 할 수 있는 좋아요 기능이 포함돼있다.
기존에는 리뷰의 좋아요 개수를 관리하는 likeCount
변수를 통해서 관리했다.
하지만 likeCount
변수는 동시성 문제를 유발했다.
현재 LikeReview 테이블은 아래와 같이 구성돼 있다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LikeReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private Long reviewId;
}
그리고 Review
테이블은 해당 LikeReview
의 개수인 likeCount
라는 변수를 가지고 있는 상황이라고 해보자.
사용자인 A 와 B 그리고 리뷰인 review 가 있을 때,
A 와 B 가 review 에 대해서 동시에 좋아요를 했다.
A 가 사용하는 트랜잭션에서는 리뷰를 조회했을 때 review 의 likeCount 가 0이다.
그래서 likeReview 새롭게 만들고 review 의 likeCount 도 1 증가시켜서 1이 되었다.
B 에 경우에도 마찬가지다. A 와 동시에 review
자원에 접근하므로 A 와 똑같이 likeReview
새롭게 만들고 review
의 likeCount
도 1 증가시켜서 1이 된다.
결국 두번의 좋아요 실행에도 likeCount
는 1이 될 것이다.
나는 세가지 해결방법을 생각했다.
1번 DB 락
2번 likeCount 변수를 없애고 조인 쿼리를 통해서 좋아요 개수 함께 조회
3번 Redis 활용
데이터베이스 Lock 에는 크게 두가지 종류가 있다. 낙관적 락, 비관적 락이다.
둘의 차이를 쉽게 설명하자면 낙관적 락은 어플리케이션 레벨의 락이며 비관적 락은 DB 레벨의 락이다.
낙관적 락을 이용하면 동시성이 발생할 때를 예외로 처리해야하는데, 이는 비즈니스 적으로 옳지 않다고 생각해서 적용하지 않았다
비관적 락은 간단하다. DB 에 락을 걸어버리는 것이다. 현재 내 코드에 적용하려면 베타락을 적용해야한다.
사용방법은 간단하게 아래 방법으로 쿼리를 구성 후 좋아요 개수를 증가시키면 된다
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Review> findWithLockById(Long id);
@Transactional
public void withLock(Long reviewId, Long userId) {
likeReviewRepository.save(LikeReview.addLike(userId, reviewId));
Review review = reviewFindService.getWithLockById(reviewId);
review.addLike();
}
현재 동시성 문제를 유발하는 변수는 likeCount
변수다.
좋아요에 대한 정보는 like_review
테이블에 존재하기 때문에 like_review
테이블을 통해서 리뷰의 좋아요 개수를 파악할 수 있다.
즉 likeCount
변수를 제거하고 좋아요 개수에 대해서는 like_review
테이블에서 가져오는 방식으로 해결할 수 있다.
나는 첫 시도에 해당 방법을 택했고, 이는 성능 문제를 야기했다
두 번째 방법을 택했기에 리뷰와 좋아요를 함께 조회할 때마다 review<<-->>like_review
두 테이블을 Join
하고 group by
하며 count
를 세는 방식으로 쿼리를 짰다.
아래 쿼리가 내가 사용한 쿼리다.
like_review
테이블을 left join
하고 case
문을 사용해서 좋아요가 없는 리뷰에 대해서는 0을 할당하는 방식이다. 그리고 group by
를 적용해서 최종적으로 review
의 정보와 review
의 좋아요 개수를 얻어내는 쿼리다.
## like_review 조인 + group by
select
r1_0.id,
r1_0.contents,
r1_0.created_date,
h1_0.id,
h1_0.name,
h1_0.sex,
r1_0.score,
r1_0.updated_date,
w1_0.id,
w1_0.email,
w1_0.tag,
w1_0.name,
w1_0.nickname,
w1_0.pw,
w1_0.sex,
case
when (sum(l1_0.id) is null) then cast(0 as signed)
else count(r1_0.id)
end
from
review r1_0
left join
like_review l1_0
on r1_0.id=l1_0.review_id
left join
hair_style h1_0
on h1_0.id=r1_0.hair_style_id
left join
users w1_0
on w1_0.id=r1_0.user_id
group by
r1_0.id limit 0,
5;
해당 쿼리는 좋아요 개수가 많아질 수록 조인되는 컬럼이 복사가돼서 분명히 성능이 좋지 않을 것이라고 생각했다.
그래서 like_review 테이블에 대한 조인이 없는 쿼리와 성능을 비교해봤다.
아래 쿼리는 like_review 에 대한 조인과 group by 가 없는 쿼리다.
해당 쿼리에서는 각 리뷰에 대한 좋아요의 개수에 대해서는 파악하지 않는다.
## like_review 조인, group by X
select
r1_0.id,
r1_0.contents,
r1_0.created_date,
h1_0.id,
h1_0.name,
h1_0.sex,
r1_0.score,
r1_0.updated_date,
w1_0.id,
w1_0.email,
w1_0.tag,
w1_0.name,
w1_0.nickname,
w1_0.pw,
w1_0.sex
from
review r1_0
left join
hair_style h1_0
on h1_0.id=r1_0.hair_style_id
left join
users w1_0
on w1_0.id=r1_0.user_id limit 0,
5;
자세한 성능 테스트는 👉 좋아요 관련 기능 성능 테스트 포스팅 해당 포스팅에 자세히 설명했다
성능 테스트 결과는 좋아요에 대해서 조인한 쿼리의
TPS
가 7이 나왔고, 좋아요에 대한 조인이 없는 경우는TPS
가 159가 나오며 큰 성능 차이를 보여줬다.(자세한 테스트 환경과 조건은 위 링크에 있습니다)
그래서 결국 해당 방법은 포기했다 😢
이제 남은 방법은 비관적 락을 활용한 방법과 Redis 를 활용한 방법이다.
우선 Redis 를 활용하는 방법에 대해서 알아보고 둘을 비교해보겠다
Redis 를 활용한 방법은 Redis 의 INCR
명령어를 이용하는 방법이 있다.
Redis 는 싱글 스레드로 작동하기 때문에 Redis 의 INCR
명령어 자체로는 동시성을 보장해준다
하지만 데이터를 조회하고 값을 변경하는 등 INCR 명령어 말고도 여러 명령어가 조합돼있으면 이 과정에서는 동시성 이슈가 발생할 수 있다.
@Transactional
public boolean executeLike(Long reviewId, Long userId) {
likeReviewRepository.save(LikeReview.addLike(userId, reviewId));
//락을 걸지않고 값이없으면 좋아요 개수를 로드해서 반영 기능 추가
redisUtils.getData(reviewId)
.ifPresentOrElse(
likeCount -> redisUtils.increaseData(reviewId),
() -> updateLikeCountFromRedis(reviewId)
);
}
private Long updateLikeCountFromRedis(Long reviewId) {
Long likeCount = likeReviewRepository.countByReviewId(reviewId);
redisUtils.setData(reviewId, likeCount);
return likeCount;
}
로직 흐름
1. 좋아요를 실행
2. Redis 에서 값을 reviewId(key) 에 대한 값을 조회
3. 값이 있으면INCR
호출
4. 값이 없으면 DB 에서 좋아요 수를 조회 한 후 저장
아까 위에서 언급했는데, Redis 는 명령어에 조합에 대해서는 동시성 이슈가 발생할 수 있다.
내가 만든 흐름에서 2 → 3 → 4 흐름에서 딱 그런 상황이다.
조회하고 값을 확인한 후 INCR
명령어로 이어지거나 값을 변경하기 때문에 만약 Redis 에 key 에 해당하는 데이터가 없을 때 동시적으로 요청이 들어오면 각 요청들은 값이 없다는 결과를 가지고 4번 과정을 수행할 것이다.
결국 Redis 에 데이터가 없을 때 동시적으로 요청이 들어오면 동시성 이슈가 발생한다.
두 방법에 대해서 두가지 측면에서 비교해보겠다
1. 성능
자세한 성능 테스트는 👉 좋아요 관련 기능 성능 테스트 포스팅 해당 포스팅에 자세히 설명했다
비관적 락을 사용한 방법은
TPS
가 70이 나왔고, Redis 를 사용한 방법은TPS
가 177이 나오며 큰 성능차이를 보여줬다
(자세한 테스트 환경과 조건은 위 링크에 있습니다)
2. 동시성 제어
그래서 나는 결국 Redis 를 통한 방법을 택했다
이유는 아래와 같다.
레디스를 사용한 방법을 직접 테스트해봤다.
시나리오는 두 가지다.
좋아요 정보가 Redis 에 있을 때에는 동시성 이슈가 발생하지 않는다.
좋아요 정보가 Redis 에 없을 때, 동시적 요청이 들어오면 동시성 이슈가 발생한다
코드를 보겠다. 먼저 1번 시나리오 코드다.
@Test
@DisplayName("[좋아요 정보가 Redis 에 있을 때에는 동시성 이슈가 발생하지 않는다.]")
void success() throws InterruptedException {
//given
int threadCount = 100;
ExecutorService service = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
//데이터가 존재할 때는 동시성 해결
likeReviewService.executeLike(1L, 1L);
//when
for (int i = 0; i < threadCount; i++) {
service.execute(() -> {
Random random = new Random();
int id = random.nextInt(Integer.MAX_VALUE);
likeReviewService.executeLike(1L, (long)id);
latch.countDown();
});
}
latch.await();
//then
Long likeCount = likeReviewService.getLikeCount(1L);
assertThat(likeCount).isEqualTo(101);
}
아래는 2번 시나리오 코드다.
@Test
@DisplayName("[좋아요 정보가 Redis 에 없을 때, 동시적 요청이 들어오면 동시성 이슈가 발생한다]")
void fail() throws InterruptedException {
//given
int threadCount = 100;
ExecutorService service = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
//when
for (int i = 0; i < threadCount; i++) {
service.execute(() -> {
Random random = new Random();
int id = random.nextInt(Integer.MAX_VALUE);
likeReviewService.executeLike(1L, (long)id);
latch.countDown();
});
}
latch.await();
//then -> 동시성 이슈 발생으로 100개의 좋아요 보다 적은 likeCount
Long likeCount = likeReviewService.getLikeCount(1L);
assertThat(likeCount).isLessThan(100L);
}
실행시켜보면 아래와 같은 결과를 얻을 수 있으며, 시나리오대로 실행되는 것을 볼 수 있다.
좋아요 라는 기능에 대해서 발생할 수 있는 동시성 이슈와 성능 이슈를 다루면서 일련의 해결과정을 전부 담아봤다.
그 과정에서 좋아요 라는 기능에 대해서 많이 생각해봤고, 여러 방법과 기술에 대해서 공부할 수 있는 유익한 시간이었다. 이 포스팅이 누군가에게 도움이 됐으면 좋겠다 🫡