Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[No-jira] 낙관락과 Spring Retry를 통한 리뷰 좋아요 동시성 제어 #208

Merged
merged 8 commits into from
Nov 21, 2024
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
Loading