Skip to content

Commit

Permalink
[No-jira] 낙관락과 Spring Retry를 통한 리뷰 좋아요 동시성 제어 (#208)
Browse files Browse the repository at this point in the history
* feat: 리뷰 엔티티와 도메인에 @Version 컬럼 추가

* feat: 리뷰 엔티티에 likesCount 증감 메서드 추가

* feat: 리뷰 레포지토리에 낙관락 기반으로 좋아요 수 업데이트 구현

* feat: 리뷰엔티티 version 컬럼 디폴트 0L 설정

* feat: 리뷰 좋아요 토글 메서드에 낙관락 및 재시도 로직 구현

* test: 멀티스레드 낙관락 정합성 테스트

* feat: setter로 hibernate 변경 감지 가능하도록 변경

* feat: spring retry 적용헤 재시도 로직 구현
  • Loading branch information
pminsung12 authored Nov 21, 2024
1 parent 98a3998 commit fc9c06d
Show file tree
Hide file tree
Showing 17 changed files with 381 additions and 203 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ public class ReviewLikeController {
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "특정 리뷰에 공감한다. 만약 이전에 공감했던 리뷰라면, 공감을 취소한다.")
@PostMapping("/{reviewId}/like")
public void toggleLike(
public boolean toggleLike(
@PathVariable @Positive @NotNull final Long reviewId,
@Parameter(hidden = true) Long memberId) {
boolean result = reviewLikeUsecase.toggleLike(memberId, reviewId);
return reviewLikeUsecase.toggleLike(memberId, reviewId);
// if (result) {
// // 리뷰 공감 추이 이벤트 발생
// applicationEventPublisher.publishEvent(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,119 +1,129 @@
// package org.depromeet.spot.application;
//
// import static org.junit.jupiter.api.Assertions.assertEquals;
//
// import java.util.concurrent.CountDownLatch;
// import java.util.concurrent.ExecutorService;
// import java.util.concurrent.Executors;
// import java.util.concurrent.atomic.AtomicLong;
//
// import org.depromeet.spot.domain.member.Level;
// import org.depromeet.spot.domain.member.Member;
// import org.depromeet.spot.domain.member.enums.MemberRole;
// import org.depromeet.spot.domain.member.enums.SnsProvider;
// import org.depromeet.spot.domain.review.Review;
// import org.depromeet.spot.usecase.port.in.review.ReadReviewUsecase;
// import org.depromeet.spot.usecase.port.out.member.LevelRepository;
// import org.depromeet.spot.usecase.port.out.member.MemberRepository;
// import org.depromeet.spot.usecase.service.review.like.ReviewLikeService;
// import org.junit.jupiter.api.BeforeEach;
// import org.junit.jupiter.api.Test;
// import org.springframework.beans.factory.annotation.Autowired;
// import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
// import org.springframework.boot.test.context.SpringBootTest;
// import org.springframework.test.context.ActiveProfiles;
// import org.springframework.test.context.TestPropertySource;
// import org.springframework.test.context.jdbc.Sql;
// import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
// import org.springframework.test.context.jdbc.SqlGroup;
// import org.springframework.transaction.annotation.Transactional;
// import org.testcontainers.junit.jupiter.Testcontainers;
//
// import lombok.extern.slf4j.Slf4j;
//
// @Slf4j
// @SpringBootTest
// @Testcontainers
// @ActiveProfiles("test")
// @TestPropertySource("classpath:application-test.yml")
// @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// @SqlGroup({
// @Sql(
// value = "/sql/delete-data-after-review-like.sql",
// executionPhase = ExecutionPhase.AFTER_TEST_METHOD),
// @Sql(
// value = "/sql/review-like-service-data.sql",
// executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
// })
// class ReviewLikeServiceTest {
//
// @Autowired private ReviewLikeService reviewLikeService;
//
// @Autowired private ReadReviewUsecase readReviewUsecase;
//
// @Autowired private MemberRepository memberRepository;
//
// @Autowired private LevelRepository levelRepository;
//
// private static final int NUMBER_OF_THREAD = 100;
//
// @BeforeEach
// @Transactional
// void init() {
// Level level = levelRepository.findByValue(0);
// AtomicLong memberIdGenerator = new AtomicLong(1);
//
// for (int i = 0; i < NUMBER_OF_THREAD; i++) {
// long memberId = memberIdGenerator.getAndIncrement();
// memberRepository.save(
// Member.builder()
// .id(memberId)
// .snsProvider(SnsProvider.KAKAO)
// .teamId(1L)
// .role(MemberRole.ROLE_ADMIN)
// .idToken("idToken" + memberId)
// .nickname(String.valueOf(memberId))
// .phoneNumber(String.valueOf(memberId))
// .email("email" + memberId)
// .build(),
// level);
// }
// }
//
// @Test
// void 멀티_스레드_환경에서_리뷰_공감_수를_정상적으로_증가시킬_수_있다() throws InterruptedException {
// // given
// final long reviewId = 1L;
// AtomicLong memberIdGenerator = new AtomicLong(1);
// final ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_THREAD);
// final CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREAD);
//
// // when
// for (int i = 0; i < NUMBER_OF_THREAD; i++) {
// long memberId = memberIdGenerator.getAndIncrement();
// executorService.execute(
// () -> {
// try {
// reviewLikeService.toggleLike(memberId, reviewId);
// System.out.println(
// "Thread " + Thread.currentThread().getId() + " - 성공");
// } catch (Throwable e) {
// System.out.println(
// "Thread "
// + Thread.currentThread().getId()
// + " - 실패"
// + e.getClass().getName());
// e.printStackTrace();
// } finally {
// latch.countDown();
// }
// });
// }
// latch.await();
// executorService.shutdown();
//
// // then
// Review review = readReviewUsecase.findById(reviewId);
// assertEquals(100, review.getLikesCount());
// }
// }
package org.depromeet.spot.application;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.ConcurrentModificationException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

import org.depromeet.spot.domain.member.Level;
import org.depromeet.spot.domain.member.Member;
import org.depromeet.spot.domain.member.enums.MemberRole;
import org.depromeet.spot.domain.member.enums.SnsProvider;
import org.depromeet.spot.domain.review.Review;
import org.depromeet.spot.usecase.port.out.member.LevelRepository;
import org.depromeet.spot.usecase.port.out.member.MemberRepository;
import org.depromeet.spot.usecase.port.out.review.ReviewRepository;
import org.depromeet.spot.usecase.service.review.like.ReviewLikeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
import org.springframework.test.context.jdbc.SqlGroup;
import org.springframework.transaction.support.TransactionTemplate;
import org.testcontainers.junit.jupiter.Testcontainers;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
@TestPropertySource("classpath:application-test.yml")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@SqlGroup({
@Sql(
value = "/sql/delete-data-after-review-like.sql",
executionPhase = ExecutionPhase.AFTER_TEST_METHOD),
@Sql(
value = "/sql/review-like-service-data.sql",
executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
})
class ReviewLikeServiceTest {

@Autowired private ReviewLikeService reviewLikeService;
@Autowired private ReviewRepository reviewRepository;
@Autowired private MemberRepository memberRepository;
@Autowired private LevelRepository levelRepository;
@Autowired private TransactionTemplate transactionTemplate;

private static final int NUMBER_OF_THREADS = 100;

@BeforeEach
void init() {
transactionTemplate.execute(
status -> {
Level level = levelRepository.findByValue(0);
AtomicLong memberIdGenerator = new AtomicLong(1);

for (int i = 0; i < NUMBER_OF_THREADS; i++) {
long memberId = memberIdGenerator.getAndIncrement();
memberRepository.save(
Member.builder()
.id(memberId)
.snsProvider(SnsProvider.KAKAO)
.teamId(1L)
.role(MemberRole.ROLE_ADMIN)
.idToken("idToken" + memberId)
.nickname(String.valueOf(memberId))
.phoneNumber(String.valueOf(memberId))
.email("email" + memberId)
.build(),
level);
}
return null;
});
}

@Test
void 멀티_스레드_환경에서_리뷰_공감_수를_정상적으로_시킬__있다() throws InterruptedException {
// given
final long reviewId = 1L;
AtomicLong memberIdGenerator = new AtomicLong(1);
final ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
final CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREADS);
final AtomicInteger retryCount = new AtomicInteger(0);
final AtomicInteger successCount = new AtomicInteger(0);
final AtomicInteger failCount = new AtomicInteger(0);

// when
for (int i = 0; i < NUMBER_OF_THREADS; i++) {
long memberId = memberIdGenerator.getAndIncrement();
executorService.execute(
() -> {
try {
reviewLikeService.toggleLike(memberId, reviewId);
successCount.incrementAndGet();
} catch (ObjectOptimisticLockingFailureException e) {
retryCount.incrementAndGet();
} catch (ConcurrentModificationException e) {
failCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();

// then
Review review =
transactionTemplate.execute(
status -> {
return reviewRepository.findReviewByIdWithLock(reviewId);
});

assertEquals(successCount.get(), review.getLikesCount(), "좋아요 수가 성공한 요청 수와 일치해야 함");
}
}
6 changes: 5 additions & 1 deletion application/src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,13 @@ spring:
jpa:
database: mysql
hibernate:
ddl-auto: create
ddl-auto: create-drop
properties:
hibernate:
format_sql: true
database-platform: org.hibernate.dialect.MySQL8Dialect
defer-datasource-initialization: true

jwt:
secret: ${JWT_SECRETKEY}

Expand Down
Loading

0 comments on commit fc9c06d

Please sign in to comment.