Skip to content

Commit 9d6d2fd

Browse files
committed
feature: 좋아요와 싫어요 시 발생할 수 있는 동시성 제어를 위한 비관적 락 추가 및 관련 테스트 코드 작성
1 parent 111bc5f commit 9d6d2fd

6 files changed

Lines changed: 194 additions & 3 deletions

File tree

apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,36 @@
88
import java.util.Optional;
99
import lombok.RequiredArgsConstructor;
1010
import org.springframework.stereotype.Component;
11+
import org.springframework.transaction.annotation.Transactional;
1112

1213
@RequiredArgsConstructor
1314
@Component
1415
public class LikeFacade {
1516

16-
private final UserService userService;
1717
private final ProductService productService;
1818
private final LikeService likeService;
1919

20+
@Transactional
2021
public LikeInfo like(long userId, long productId) {
2122
Optional<Like> existingLike = likeService.findLike(userId, productId);
22-
Product product = productService.getProduct(productId);
23+
2324

2425
if (existingLike.isPresent()) {
26+
Product product = productService.getProduct(productId);
2527
return LikeInfo.from(existingLike.get(), product.getLikeCount());
2628
}
2729

30+
Product product = productService.getProductWithLock(productId);
2831
Like newLike = likeService.save(userId, productId);
2932
int updatedLikeCount = productService.increaseLikeCount(product);
3033

3134
return LikeInfo.from(newLike, updatedLikeCount);
3235
}
3336

37+
@Transactional
3438
public int unLike(long userId, long productId) {
3539
likeService.unLike(userId, productId);
36-
Product product = productService.getProduct(productId);
40+
Product product = productService.getProductWithLock(productId);
3741
return productService.decreaseLikeCount(product);
3842
}
3943
}

apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ public interface ProductRepository {
1313
Optional<Product> findById(Long id);
1414

1515
Page<Product> findByBrandId(Long brandId, Pageable pageable);
16+
17+
Optional<Product> findByIdWithLock(Long id);
1618
}

apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,9 @@ public int increaseLikeCount(Product product) {
6161
public int decreaseLikeCount(Product product) {
6262
return product.decreaseLikeCount();
6363
}
64+
65+
public Product getProductWithLock(Long id) {
66+
return productRepository.findByIdWithLock(id)
67+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
68+
}
6469
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
package com.loopers.infrastructure.product;
22

33
import com.loopers.domain.product.Product;
4+
import jakarta.persistence.LockModeType;
5+
import java.util.Optional;
46
import org.springframework.data.domain.Page;
57
import org.springframework.data.domain.Pageable;
68
import org.springframework.data.jpa.repository.JpaRepository;
9+
import org.springframework.data.jpa.repository.Lock;
10+
import org.springframework.data.jpa.repository.Query;
711

812
public interface ProductJpaRepository extends JpaRepository<Product, Long> {
913

1014
Page<Product> findByBrandId(Long brandId, Pageable pageable);
15+
16+
@Lock(LockModeType.PESSIMISTIC_WRITE)
17+
@Query("select p from Product p where p.id = :id")
18+
Optional<Product> findByIdWithLock(Long id);
1119
}

apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,9 @@ public Optional<Product> findById(Long id) {
3333
public Page<Product> findByBrandId(Long brandId, Pageable pageable) {
3434
return productJpaRepository.findByBrandId(brandId, pageable);
3535
}
36+
37+
@Override
38+
public Optional<Product> findByIdWithLock(Long id) {
39+
return productJpaRepository.findByIdWithLock(id);
40+
}
3641
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package com.loopers.application.like;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.loopers.domain.money.Money;
6+
import com.loopers.domain.product.Product;
7+
import com.loopers.domain.product.ProductRepository;
8+
import com.loopers.domain.user.User;
9+
import com.loopers.infrastructure.user.UserJpaRepository;
10+
import java.util.List;
11+
import java.util.concurrent.CountDownLatch;
12+
import java.util.concurrent.ExecutorService;
13+
import java.util.concurrent.Executors;
14+
import java.util.concurrent.atomic.AtomicInteger;
15+
import java.util.stream.IntStream;
16+
import org.junit.jupiter.api.AfterEach;
17+
import org.junit.jupiter.api.BeforeEach;
18+
import org.junit.jupiter.api.DisplayName;
19+
import org.junit.jupiter.api.Nested;
20+
import org.junit.jupiter.api.Test;
21+
import org.springframework.beans.factory.annotation.Autowired;
22+
import org.springframework.boot.test.context.SpringBootTest;
23+
24+
@SpringBootTest
25+
public class LikeConcurrencyTest {
26+
@Autowired
27+
private LikeFacade likeFacade;
28+
29+
@Autowired
30+
private ProductRepository productRepository;
31+
32+
@Autowired
33+
private UserJpaRepository userJpaRepository;
34+
35+
@Autowired
36+
private com.loopers.utils.DatabaseCleanUp databaseCleanUp;
37+
38+
@AfterEach
39+
void tearDown() {
40+
databaseCleanUp.truncateAllTables();
41+
}
42+
43+
@Nested
44+
@DisplayName("좋아요 증가 동시성 (비관적 락)")
45+
class LikeIncreaseConcurrency {
46+
47+
private Product targetProduct;
48+
private List<User> users;
49+
50+
@BeforeEach
51+
void setUp() {
52+
Product product = new Product(1l, "인기상품", "설명", new Money(10000L), 100);
53+
this.targetProduct = productRepository.save(product);
54+
55+
this.users = IntStream.range(0, 10)
56+
.mapToObj(i -> userJpaRepository.save(
57+
new User("user" + i, "user" + i + "@email.com", "2000-01-01", User.Gender.MALE)
58+
))
59+
.toList();
60+
}
61+
62+
@Test
63+
@DisplayName("동시에 10명이 좋아요를 누르면, 상품의 좋아요 개수는 정확히 10개가 되어야 한다.")
64+
void like_concurrency_test() throws InterruptedException {
65+
// arrange
66+
int threadCount = 10;
67+
ExecutorService executorService = Executors.newFixedThreadPool(32);
68+
CountDownLatch latch = new CountDownLatch(threadCount);
69+
70+
AtomicInteger successCount = new AtomicInteger();
71+
AtomicInteger failCount = new AtomicInteger();
72+
73+
// act
74+
for (int i = 0; i < threadCount; i++) {
75+
final int index = i;
76+
executorService.submit(() -> {
77+
try {
78+
likeFacade.like(users.get(index).getId(), targetProduct.getId());
79+
successCount.getAndIncrement();
80+
} catch (Exception e) {
81+
System.out.println("좋아요 실패: " + e.getMessage());
82+
failCount.getAndIncrement();
83+
} finally {
84+
latch.countDown();
85+
}
86+
});
87+
}
88+
89+
latch.await();
90+
91+
Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow();
92+
93+
System.out.println("좋아요 성공: " + successCount.get() + ", 실패: " + failCount.get());
94+
95+
// assert
96+
assertThat(successCount.get()).isEqualTo(threadCount);
97+
assertThat(resultProduct.getLikeCount()).isEqualTo(10);
98+
}
99+
}
100+
101+
@Nested
102+
@DisplayName("좋아요 취소 동시성 (비관적 락)")
103+
class LikeDecreaseConcurrency {
104+
105+
private Product targetProduct;
106+
private List<User> users;
107+
108+
@BeforeEach
109+
void setUp() {
110+
Product product = new Product(1l, "인기상품", "설명", new Money(10000L), 100);
111+
this.targetProduct = productRepository.save(product);
112+
113+
this.users = IntStream.range(0, 10)
114+
.mapToObj(i -> userJpaRepository.save(
115+
new User("user" + i, "user" + i + "@email.com", "2000-01-01", User.Gender.MALE)
116+
))
117+
.toList();
118+
119+
for (User user : users) {
120+
likeFacade.like(user.getId(), targetProduct.getId());
121+
}
122+
}
123+
124+
@Test
125+
@DisplayName("이미 좋아요를 누른 10명이 동시에 취소를 요청하면, 좋아요 개수는 0개가 되어야 한다.")
126+
void unlike_concurrency_test() throws InterruptedException {
127+
// arrange
128+
int threadCount = 10;
129+
130+
Product initialProduct = productRepository.findById(targetProduct.getId()).orElseThrow();
131+
assertThat(initialProduct.getLikeCount()).isEqualTo(10);
132+
133+
ExecutorService executorService = Executors.newFixedThreadPool(32);
134+
CountDownLatch latch = new CountDownLatch(threadCount);
135+
136+
AtomicInteger successCount = new AtomicInteger();
137+
AtomicInteger failCount = new AtomicInteger();
138+
139+
// act
140+
for (int i = 0; i < threadCount; i++) {
141+
final int index = i;
142+
executorService.submit(() -> {
143+
try {
144+
likeFacade.unLike(users.get(index).getId(), targetProduct.getId());
145+
successCount.getAndIncrement();
146+
} catch (Exception e) {
147+
System.out.println("취소 실패: " + e.getMessage());
148+
failCount.getAndIncrement();
149+
} finally {
150+
latch.countDown();
151+
}
152+
});
153+
}
154+
155+
latch.await();
156+
157+
158+
Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow();
159+
160+
System.out.println("취소 성공: " + successCount.get() + ", 실패: " + failCount.get());
161+
162+
// assert
163+
assertThat(successCount.get()).isEqualTo(threadCount);
164+
assertThat(resultProduct.getLikeCount()).isZero();
165+
}
166+
}
167+
}

0 commit comments

Comments
 (0)