-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[No-jira] 낙관락과 Spring Retry를 통한 리뷰 좋아요 동시성 제어 (#208)
* feat: 리뷰 엔티티와 도메인에 @Version 컬럼 추가 * feat: 리뷰 엔티티에 likesCount 증감 메서드 추가 * feat: 리뷰 레포지토리에 낙관락 기반으로 좋아요 수 업데이트 구현 * feat: 리뷰엔티티 version 컬럼 디폴트 0L 설정 * feat: 리뷰 좋아요 토글 메서드에 낙관락 및 재시도 로직 구현 * test: 멀티스레드 낙관락 정합성 테스트 * feat: setter로 hibernate 변경 감지 가능하도록 변경 * feat: spring retry 적용헤 재시도 로직 구현
- Loading branch information
1 parent
98a3998
commit fc9c06d
Showing
17 changed files
with
381 additions
and
203 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
248 changes: 129 additions & 119 deletions
248
application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), "좋아요 수가 성공한 요청 수와 일치해야 함"); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.