좋아요 동시성 이슈 해결과 성능 최적화 경험기

은찬·2023년 10월 31일
6

Java, Spring

목록 보기
5/7

좋아요 동시성 이슈 해결과 성능 최적화

내 프로젝트에서 좋아요 기능에 대해서 겪은 이슈와 여러 과정을 통해 알게된 해결방법과 최종적으로 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 새롭게 만들고 reviewlikeCount 도 1 증가시켜서 1이 된다.

결국 두번의 좋아요 실행에도 likeCount 는 1이 될 것이다.

동시성 문제 해결방법

나는 세가지 해결방법을 생각했다.

1번 DB 락

2번 likeCount 변수를 없애고 조인 쿼리를 통해서 좋아요 개수 함께 조회

3번 Redis 활용

DB Lock 을 통한 해결방법

데이터베이스 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 라는 변수를 포기하는 방법

현재 동시성 문제를 유발하는 변수는 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 를 활용한 방법은 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 에 데이터가 없을 때 동시적으로 요청이 들어오면 동시성 이슈가 발생한다.

  • 장점 : 락을 건 방법 보다 빠름, Redis 에 데이터가 없는 상황이 아니라면 동시성 보장
  • 단점 : Redis 에 데이터가 없을 때 동시성 이슈가 생길 수 있음.

Redis 와 비관적 락 비교

두 방법에 대해서 두가지 측면에서 비교해보겠다

1. 성능

자세한 성능 테스트는 👉 좋아요 관련 기능 성능 테스트 포스팅 해당 포스팅에 자세히 설명했다

비관적 락을 사용한 방법은 TPS가 70이 나왔고, Redis 를 사용한 방법은 TPS 가 177이 나오며 큰 성능차이를 보여줬다
(자세한 테스트 환경과 조건은 위 링크에 있습니다)

2. 동시성 제어

  • 비관적 락은 어떤 상황에서든 확실한 동시성 제어를 보장한다
  • Redis 를 활용한 방법은 대상 데이터가 없을 때 혹은 분산 환경에서 동시성을 확실하게 보장하지 못한다

Redis 를 선택한 이유

그래서 나는 결국 Redis 를 통한 방법을 택했다

이유는 아래와 같다.

  1. 확실한 성능차이
  2. 동시성 : 위에서 말했듯이 Redis 에 데이터가 없는 상황이 아니라면 동시성을 보장하며(TTL 을 잘 조정하면 동시성이 발생하기 힘들다), 동시성이 발생했다고 해도 TTL 이 만료되어 데이터가 제거되어 다시 데이터가 세팅 되는 과정이나 좋아요한 유저를 조회하는 과정을 과치면 데이터 정합이 맞춰진다. 즉 결과적으로 일관성을 보장한다.

실제 동시성 테스트

레디스를 사용한 방법을 직접 테스트해봤다.

시나리오는 두 가지다.

  1. 좋아요 정보가 Redis 에 있을 때에는 동시성 이슈가 발생하지 않는다.
  2. 좋아요 정보가 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);
	}

실행시켜보면 아래와 같은 결과를 얻을 수 있으며, 시나리오대로 실행되는 것을 볼 수 있다.

마지막 말

좋아요 라는 기능에 대해서 발생할 수 있는 동시성 이슈와 성능 이슈를 다루면서 일련의 해결과정을 전부 담아봤다.

그 과정에서 좋아요 라는 기능에 대해서 많이 생각해봤고, 여러 방법과 기술에 대해서 공부할 수 있는 유익한 시간이었다. 이 포스팅이 누군가에게 도움이 됐으면 좋겠다 🫡

profile
`강한` 백엔드 개발자라고 해두겠습니다

0개의 댓글