Skip to content

Commit fc9c06d

Browse files
authored
[No-jira] 낙관락과 Spring Retry를 통한 리뷰 좋아요 동시성 제어 (#208)
* feat: 리뷰 엔티티와 도메인에 @Version 컬럼 추가 * feat: 리뷰 엔티티에 likesCount 증감 메서드 추가 * feat: 리뷰 레포지토리에 낙관락 기반으로 좋아요 수 업데이트 구현 * feat: 리뷰엔티티 version 컬럼 디폴트 0L 설정 * feat: 리뷰 좋아요 토글 메서드에 낙관락 및 재시도 로직 구현 * test: 멀티스레드 낙관락 정합성 테스트 * feat: setter로 hibernate 변경 감지 가능하도록 변경 * feat: spring retry 적용헤 재시도 로직 구현
1 parent 98a3998 commit fc9c06d

File tree

17 files changed

+381
-203
lines changed

17 files changed

+381
-203
lines changed

application/src/main/java/org/depromeet/spot/application/review/like/ReviewLikeController.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ public class ReviewLikeController {
3131
@ResponseStatus(HttpStatus.OK)
3232
@Operation(summary = "특정 리뷰에 공감한다. 만약 이전에 공감했던 리뷰라면, 공감을 취소한다.")
3333
@PostMapping("/{reviewId}/like")
34-
public void toggleLike(
34+
public boolean toggleLike(
3535
@PathVariable @Positive @NotNull final Long reviewId,
3636
@Parameter(hidden = true) Long memberId) {
37-
boolean result = reviewLikeUsecase.toggleLike(memberId, reviewId);
37+
return reviewLikeUsecase.toggleLike(memberId, reviewId);
3838
// if (result) {
3939
// // 리뷰 공감 추이 이벤트 발생
4040
// applicationEventPublisher.publishEvent(
Lines changed: 129 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,129 @@
1-
// package org.depromeet.spot.application;
2-
//
3-
// import static org.junit.jupiter.api.Assertions.assertEquals;
4-
//
5-
// import java.util.concurrent.CountDownLatch;
6-
// import java.util.concurrent.ExecutorService;
7-
// import java.util.concurrent.Executors;
8-
// import java.util.concurrent.atomic.AtomicLong;
9-
//
10-
// import org.depromeet.spot.domain.member.Level;
11-
// import org.depromeet.spot.domain.member.Member;
12-
// import org.depromeet.spot.domain.member.enums.MemberRole;
13-
// import org.depromeet.spot.domain.member.enums.SnsProvider;
14-
// import org.depromeet.spot.domain.review.Review;
15-
// import org.depromeet.spot.usecase.port.in.review.ReadReviewUsecase;
16-
// import org.depromeet.spot.usecase.port.out.member.LevelRepository;
17-
// import org.depromeet.spot.usecase.port.out.member.MemberRepository;
18-
// import org.depromeet.spot.usecase.service.review.like.ReviewLikeService;
19-
// import org.junit.jupiter.api.BeforeEach;
20-
// import org.junit.jupiter.api.Test;
21-
// import org.springframework.beans.factory.annotation.Autowired;
22-
// import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
23-
// import org.springframework.boot.test.context.SpringBootTest;
24-
// import org.springframework.test.context.ActiveProfiles;
25-
// import org.springframework.test.context.TestPropertySource;
26-
// import org.springframework.test.context.jdbc.Sql;
27-
// import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
28-
// import org.springframework.test.context.jdbc.SqlGroup;
29-
// import org.springframework.transaction.annotation.Transactional;
30-
// import org.testcontainers.junit.jupiter.Testcontainers;
31-
//
32-
// import lombok.extern.slf4j.Slf4j;
33-
//
34-
// @Slf4j
35-
// @SpringBootTest
36-
// @Testcontainers
37-
// @ActiveProfiles("test")
38-
// @TestPropertySource("classpath:application-test.yml")
39-
// @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
40-
// @SqlGroup({
41-
// @Sql(
42-
// value = "/sql/delete-data-after-review-like.sql",
43-
// executionPhase = ExecutionPhase.AFTER_TEST_METHOD),
44-
// @Sql(
45-
// value = "/sql/review-like-service-data.sql",
46-
// executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
47-
// })
48-
// class ReviewLikeServiceTest {
49-
//
50-
// @Autowired private ReviewLikeService reviewLikeService;
51-
//
52-
// @Autowired private ReadReviewUsecase readReviewUsecase;
53-
//
54-
// @Autowired private MemberRepository memberRepository;
55-
//
56-
// @Autowired private LevelRepository levelRepository;
57-
//
58-
// private static final int NUMBER_OF_THREAD = 100;
59-
//
60-
// @BeforeEach
61-
// @Transactional
62-
// void init() {
63-
// Level level = levelRepository.findByValue(0);
64-
// AtomicLong memberIdGenerator = new AtomicLong(1);
65-
//
66-
// for (int i = 0; i < NUMBER_OF_THREAD; i++) {
67-
// long memberId = memberIdGenerator.getAndIncrement();
68-
// memberRepository.save(
69-
// Member.builder()
70-
// .id(memberId)
71-
// .snsProvider(SnsProvider.KAKAO)
72-
// .teamId(1L)
73-
// .role(MemberRole.ROLE_ADMIN)
74-
// .idToken("idToken" + memberId)
75-
// .nickname(String.valueOf(memberId))
76-
// .phoneNumber(String.valueOf(memberId))
77-
// .email("email" + memberId)
78-
// .build(),
79-
// level);
80-
// }
81-
// }
82-
//
83-
// @Test
84-
// void 멀티_스레드_환경에서_리뷰_공감_수를_정상적으로_증가시킬_수_있다() throws InterruptedException {
85-
// // given
86-
// final long reviewId = 1L;
87-
// AtomicLong memberIdGenerator = new AtomicLong(1);
88-
// final ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_THREAD);
89-
// final CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREAD);
90-
//
91-
// // when
92-
// for (int i = 0; i < NUMBER_OF_THREAD; i++) {
93-
// long memberId = memberIdGenerator.getAndIncrement();
94-
// executorService.execute(
95-
// () -> {
96-
// try {
97-
// reviewLikeService.toggleLike(memberId, reviewId);
98-
// System.out.println(
99-
// "Thread " + Thread.currentThread().getId() + " - 성공");
100-
// } catch (Throwable e) {
101-
// System.out.println(
102-
// "Thread "
103-
// + Thread.currentThread().getId()
104-
// + " - 실패"
105-
// + e.getClass().getName());
106-
// e.printStackTrace();
107-
// } finally {
108-
// latch.countDown();
109-
// }
110-
// });
111-
// }
112-
// latch.await();
113-
// executorService.shutdown();
114-
//
115-
// // then
116-
// Review review = readReviewUsecase.findById(reviewId);
117-
// assertEquals(100, review.getLikesCount());
118-
// }
119-
// }
1+
package org.depromeet.spot.application;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
5+
import java.util.ConcurrentModificationException;
6+
import java.util.concurrent.CountDownLatch;
7+
import java.util.concurrent.ExecutorService;
8+
import java.util.concurrent.Executors;
9+
import java.util.concurrent.atomic.AtomicInteger;
10+
import java.util.concurrent.atomic.AtomicLong;
11+
12+
import org.depromeet.spot.domain.member.Level;
13+
import org.depromeet.spot.domain.member.Member;
14+
import org.depromeet.spot.domain.member.enums.MemberRole;
15+
import org.depromeet.spot.domain.member.enums.SnsProvider;
16+
import org.depromeet.spot.domain.review.Review;
17+
import org.depromeet.spot.usecase.port.out.member.LevelRepository;
18+
import org.depromeet.spot.usecase.port.out.member.MemberRepository;
19+
import org.depromeet.spot.usecase.port.out.review.ReviewRepository;
20+
import org.depromeet.spot.usecase.service.review.like.ReviewLikeService;
21+
import org.junit.jupiter.api.BeforeEach;
22+
import org.junit.jupiter.api.Test;
23+
import org.springframework.beans.factory.annotation.Autowired;
24+
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
25+
import org.springframework.boot.test.context.SpringBootTest;
26+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
27+
import org.springframework.test.context.ActiveProfiles;
28+
import org.springframework.test.context.TestPropertySource;
29+
import org.springframework.test.context.jdbc.Sql;
30+
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
31+
import org.springframework.test.context.jdbc.SqlGroup;
32+
import org.springframework.transaction.support.TransactionTemplate;
33+
import org.testcontainers.junit.jupiter.Testcontainers;
34+
35+
import lombok.extern.slf4j.Slf4j;
36+
37+
@Slf4j
38+
@SpringBootTest
39+
@Testcontainers
40+
@ActiveProfiles("test")
41+
@TestPropertySource("classpath:application-test.yml")
42+
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
43+
@SqlGroup({
44+
@Sql(
45+
value = "/sql/delete-data-after-review-like.sql",
46+
executionPhase = ExecutionPhase.AFTER_TEST_METHOD),
47+
@Sql(
48+
value = "/sql/review-like-service-data.sql",
49+
executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
50+
})
51+
class ReviewLikeServiceTest {
52+
53+
@Autowired private ReviewLikeService reviewLikeService;
54+
@Autowired private ReviewRepository reviewRepository;
55+
@Autowired private MemberRepository memberRepository;
56+
@Autowired private LevelRepository levelRepository;
57+
@Autowired private TransactionTemplate transactionTemplate;
58+
59+
private static final int NUMBER_OF_THREADS = 100;
60+
61+
@BeforeEach
62+
void init() {
63+
transactionTemplate.execute(
64+
status -> {
65+
Level level = levelRepository.findByValue(0);
66+
AtomicLong memberIdGenerator = new AtomicLong(1);
67+
68+
for (int i = 0; i < NUMBER_OF_THREADS; i++) {
69+
long memberId = memberIdGenerator.getAndIncrement();
70+
memberRepository.save(
71+
Member.builder()
72+
.id(memberId)
73+
.snsProvider(SnsProvider.KAKAO)
74+
.teamId(1L)
75+
.role(MemberRole.ROLE_ADMIN)
76+
.idToken("idToken" + memberId)
77+
.nickname(String.valueOf(memberId))
78+
.phoneNumber(String.valueOf(memberId))
79+
.email("email" + memberId)
80+
.build(),
81+
level);
82+
}
83+
return null;
84+
});
85+
}
86+
87+
@Test
88+
void 멀티_스레드_환경에서_리뷰_공감_수를_정상적으로_증가시킬_수_있다() throws InterruptedException {
89+
// given
90+
final long reviewId = 1L;
91+
AtomicLong memberIdGenerator = new AtomicLong(1);
92+
final ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
93+
final CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREADS);
94+
final AtomicInteger retryCount = new AtomicInteger(0);
95+
final AtomicInteger successCount = new AtomicInteger(0);
96+
final AtomicInteger failCount = new AtomicInteger(0);
97+
98+
// when
99+
for (int i = 0; i < NUMBER_OF_THREADS; i++) {
100+
long memberId = memberIdGenerator.getAndIncrement();
101+
executorService.execute(
102+
() -> {
103+
try {
104+
reviewLikeService.toggleLike(memberId, reviewId);
105+
successCount.incrementAndGet();
106+
} catch (ObjectOptimisticLockingFailureException e) {
107+
retryCount.incrementAndGet();
108+
} catch (ConcurrentModificationException e) {
109+
failCount.incrementAndGet();
110+
} catch (Exception e) {
111+
failCount.incrementAndGet();
112+
} finally {
113+
latch.countDown();
114+
}
115+
});
116+
}
117+
latch.await();
118+
executorService.shutdown();
119+
120+
// then
121+
Review review =
122+
transactionTemplate.execute(
123+
status -> {
124+
return reviewRepository.findReviewByIdWithLock(reviewId);
125+
});
126+
127+
assertEquals(successCount.get(), review.getLikesCount(), "좋아요 수가 성공한 요청 수와 일치해야 함");
128+
}
129+
}

application/src/test/resources/application-test.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,13 @@ spring:
3434
jpa:
3535
database: mysql
3636
hibernate:
37-
ddl-auto: create
37+
ddl-auto: create-drop
38+
properties:
39+
hibernate:
40+
format_sql: true
3841
database-platform: org.hibernate.dialect.MySQL8Dialect
3942
defer-datasource-initialization: true
43+
4044
jwt:
4145
secret: ${JWT_SECRETKEY}
4246

0 commit comments

Comments
 (0)