Skip to content

Commit abd2f63

Browse files
committed
refactor: 비관적 락에서 낙관적 락으로 변경
- 멘토링 후 정합성이 요구되지 않는 곳이라 낙관적 락으로 변경합니다.
1 parent 9d6d2fd commit abd2f63

8 files changed

Lines changed: 132 additions & 130 deletions

File tree

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

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44
import com.loopers.domain.like.LikeService;
55
import com.loopers.domain.product.Product;
66
import com.loopers.domain.product.ProductService;
7-
import com.loopers.domain.user.UserService;
87
import java.util.Optional;
98
import lombok.RequiredArgsConstructor;
9+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
1010
import org.springframework.stereotype.Component;
11-
import org.springframework.transaction.annotation.Transactional;
1211

1312
@RequiredArgsConstructor
1413
@Component
@@ -17,7 +16,8 @@ public class LikeFacade {
1716
private final ProductService productService;
1817
private final LikeService likeService;
1918

20-
@Transactional
19+
private static final int RETRY_COUNT = 30;
20+
2121
public LikeInfo like(long userId, long productId) {
2222
Optional<Like> existingLike = likeService.findLike(userId, productId);
2323

@@ -27,17 +27,48 @@ public LikeInfo like(long userId, long productId) {
2727
return LikeInfo.from(existingLike.get(), product.getLikeCount());
2828
}
2929

30-
Product product = productService.getProductWithLock(productId);
31-
Like newLike = likeService.save(userId, productId);
32-
int updatedLikeCount = productService.increaseLikeCount(product);
30+
for (int i = 0; i < RETRY_COUNT; i++) {
31+
try {
32+
33+
Like newLike = likeService.save(userId, productId);
34+
int updatedLikeCount = productService.increaseLikeCount(productId);
35+
36+
return LikeInfo.from(newLike, updatedLikeCount);
37+
} catch (ObjectOptimisticLockingFailureException e) {
38+
if (i == RETRY_COUNT - 1) {
39+
throw e;
40+
}
41+
sleep(50);
42+
}
43+
}
3344

34-
return LikeInfo.from(newLike, updatedLikeCount);
45+
throw new IllegalStateException("좋아요 처리 재시도 횟수를 초과했습니다.");
3546
}
3647

37-
@Transactional
3848
public int unLike(long userId, long productId) {
39-
likeService.unLike(userId, productId);
40-
Product product = productService.getProductWithLock(productId);
41-
return productService.decreaseLikeCount(product);
49+
50+
for (int i = 0; i < RETRY_COUNT; i++) {
51+
try {
52+
likeService.unLike(userId, productId);
53+
54+
return productService.decreaseLikeCount(productId);
55+
} catch (ObjectOptimisticLockingFailureException e) {
56+
if (i == RETRY_COUNT - 1) {
57+
throw e;
58+
}
59+
sleep(50);
60+
}
61+
}
62+
63+
throw new IllegalStateException("싫어요 처리 재시도 횟수를 초과했습니다.");
64+
}
65+
66+
private void sleep(long millis) {
67+
try {
68+
Thread.sleep(millis);
69+
} catch (InterruptedException e) {
70+
Thread.currentThread().interrupt();
71+
throw new RuntimeException(e);
72+
}
4273
}
4374
}

apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.Optional;
44
import lombok.RequiredArgsConstructor;
55
import org.springframework.stereotype.Component;
6+
import org.springframework.transaction.annotation.Transactional;
67

78
@Component
89
@RequiredArgsConstructor
@@ -18,6 +19,7 @@ public Optional<Like> findLike(long userId, long productId) {
1819
return likeRepository.findByUserIdAndProductId(userId, productId);
1920
}
2021

22+
@Transactional
2123
public void unLike(Long userId, Long productId) {
2224
likeRepository.deleteByUserIdAndProductId(userId, productId);
2325
}

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,4 @@ 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);
1816
}

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public Page<Product> getProducts(Pageable pageable) {
2323
return productRepository.findAll(pageable);
2424
}
2525

26+
@Transactional
2627
public Product getProduct(Long id) {
2728
return productRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
2829
}
@@ -54,16 +55,18 @@ public void deductStock(List<Product> products, List<OrderItem> orderItems) {
5455
}
5556

5657
@Transactional
57-
public int increaseLikeCount(Product product) {
58-
return product.increaseLikeCount();
59-
}
58+
public int increaseLikeCount(Long productId) {
59+
Product product = productRepository.findById(productId)
60+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
6061

61-
public int decreaseLikeCount(Product product) {
62-
return product.decreaseLikeCount();
62+
return product.increaseLikeCount();
6363
}
6464

65-
public Product getProductWithLock(Long id) {
66-
return productRepository.findByIdWithLock(id)
65+
@Transactional
66+
public int decreaseLikeCount(Long productId) {
67+
Product product = productRepository.findById(productId)
6768
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
69+
70+
return product.decreaseLikeCount();
6871
}
6972
}
Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
11
package com.loopers.infrastructure.product;
22

33
import com.loopers.domain.product.Product;
4-
import jakarta.persistence.LockModeType;
5-
import java.util.Optional;
64
import org.springframework.data.domain.Page;
75
import org.springframework.data.domain.Pageable;
86
import org.springframework.data.jpa.repository.JpaRepository;
9-
import org.springframework.data.jpa.repository.Lock;
10-
import org.springframework.data.jpa.repository.Query;
117

128
public interface ProductJpaRepository extends JpaRepository<Product, Long> {
139

1410
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);
1911
}

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,4 @@ 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-
}
4136
}

apps/commerce-api/src/test/java/com/loopers/application/like/LikeConcurrencyTest.java

Lines changed: 39 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import com.loopers.domain.user.User;
99
import com.loopers.infrastructure.user.UserJpaRepository;
1010
import java.util.List;
11-
import java.util.concurrent.CountDownLatch;
11+
import java.util.concurrent.CompletableFuture;
1212
import java.util.concurrent.ExecutorService;
1313
import java.util.concurrent.Executors;
1414
import java.util.concurrent.atomic.AtomicInteger;
@@ -23,6 +23,7 @@
2323

2424
@SpringBootTest
2525
public class LikeConcurrencyTest {
26+
2627
@Autowired
2728
private LikeFacade likeFacade;
2829

@@ -41,7 +42,7 @@ void tearDown() {
4142
}
4243

4344
@Nested
44-
@DisplayName("좋아요 증가 동시성 (비관적 락)")
45+
@DisplayName("좋아요 증가 동시성 (낙관적 락)")
4546
class LikeIncreaseConcurrency {
4647

4748
private Product targetProduct;
@@ -60,46 +61,40 @@ void setUp() {
6061
}
6162

6263
@Test
63-
@DisplayName("동시에 10명이 좋아요를 누르면, 상품의 좋아요 개수는 정확히 10개가 되어야 한다.")
64-
void like_concurrency_test() throws InterruptedException {
64+
@DisplayName("동시에 10명이 좋아요를 눌러도, 최종 좋아요 개수와 성공 요청 수는 일치해야 한다.")
65+
void like_concurrency_test() {
6566
// arrange
6667
int threadCount = 10;
6768
ExecutorService executorService = Executors.newFixedThreadPool(32);
68-
CountDownLatch latch = new CountDownLatch(threadCount);
6969

7070
AtomicInteger successCount = new AtomicInteger();
7171
AtomicInteger failCount = new AtomicInteger();
7272

7373
// 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();
74+
List<CompletableFuture<Void>> futures = IntStream.range(0, threadCount)
75+
.mapToObj(i -> CompletableFuture.runAsync(() -> {
76+
try {
77+
likeFacade.like(users.get(i).getId(), targetProduct.getId());
78+
successCount.getAndIncrement();
79+
} catch (Exception e) {
80+
System.out.println("좋아요 실패: " + e.getMessage());
81+
failCount.getAndIncrement();
82+
}
83+
}, executorService))
84+
.toList();
9085

91-
Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow();
86+
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
9287

93-
System.out.println("좋아요 성공: " + successCount.get() + ", 실패: " + failCount.get());
88+
Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow();
9489

9590
// assert
96-
assertThat(successCount.get()).isEqualTo(threadCount);
97-
assertThat(resultProduct.getLikeCount()).isEqualTo(10);
91+
assertThat(resultProduct.getLikeCount()).isEqualTo(successCount.get());
92+
assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount);
9893
}
9994
}
10095

10196
@Nested
102-
@DisplayName("좋아요 취소 동시성 (비관적 락)")
97+
@DisplayName("좋아요 취소 동시성 (낙관적 락)")
10398
class LikeDecreaseConcurrency {
10499

105100
private Product targetProduct;
@@ -116,52 +111,46 @@ void setUp() {
116111
))
117112
.toList();
118113

119-
for (User user : users) {
120-
likeFacade.like(user.getId(), targetProduct.getId());
121-
}
114+
users.forEach(user -> likeFacade.like(user.getId(), targetProduct.getId()));
115+
116+
Product initial = productRepository.findById(targetProduct.getId()).orElseThrow();
117+
assertThat(initial.getLikeCount()).isEqualTo(10);
122118
}
123119

124120
@Test
125121
@DisplayName("이미 좋아요를 누른 10명이 동시에 취소를 요청하면, 좋아요 개수는 0개가 되어야 한다.")
126-
void unlike_concurrency_test() throws InterruptedException {
122+
void unlike_concurrency_test() {
127123
// arrange
128124
int threadCount = 10;
129125

130126
Product initialProduct = productRepository.findById(targetProduct.getId()).orElseThrow();
131127
assertThat(initialProduct.getLikeCount()).isEqualTo(10);
132128

133129
ExecutorService executorService = Executors.newFixedThreadPool(32);
134-
CountDownLatch latch = new CountDownLatch(threadCount);
135130

136131
AtomicInteger successCount = new AtomicInteger();
137132
AtomicInteger failCount = new AtomicInteger();
138133

139134
// 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();
135+
List<CompletableFuture<Void>> futures = IntStream.range(0, threadCount)
136+
.mapToObj(i -> CompletableFuture.runAsync(() -> {
137+
try {
138+
likeFacade.unLike(users.get(i).getId(), targetProduct.getId());
139+
successCount.getAndIncrement();
140+
} catch (Exception e) {
141+
System.out.println("취소 실패: " + e.getMessage());
142+
failCount.getAndIncrement();
143+
}
144+
}, executorService))
145+
.toList();
156146

147+
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
157148

158149
Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow();
159150

160-
System.out.println("취소 성공: " + successCount.get() + ", 실패: " + failCount.get());
161-
162151
// assert
163-
assertThat(successCount.get()).isEqualTo(threadCount);
164152
assertThat(resultProduct.getLikeCount()).isZero();
153+
assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount);
165154
}
166155
}
167156
}

0 commit comments

Comments
 (0)