diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index 326de17ed..d6d78bd1b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -4,6 +4,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,7 +19,15 @@ * @version 1.0 */ @Entity -@Table(name = "`like`") +@Table( + name = "`like`", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_like_user_product", + columnNames = {"ref_user_id", "ref_product_id"} + ) + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Like extends BaseEntity { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java new file mode 100644 index 000000000..1e7b42394 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java @@ -0,0 +1,336 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * LikeFacade 동시성 테스트 + *

+ * 여러 스레드에서 동시에 좋아요 요청을 보내도 데이터 일관성이 유지되는지 검증합니다. + *

+ */ +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@DisplayName("LikeFacade 동시성 테스트") +class LikeFacadeConcurrencyTest { + + @Autowired + private LikeFacade likeFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + /** + * 상품들의 좋아요 수를 동기화합니다. + *

+ * 테스트에서 비동기 스케줄러를 기다리지 않고 직접 like count를 업데이트하기 위해 사용합니다. + *

+ * + * @param productIds 동기화할 상품 ID 목록 + */ + private void syncLikeCounts(List productIds) { + Map likeCountMap = likeRepository.countByProductIds(productIds); + for (Long productId : productIds) { + Long likeCount = likeCountMap.getOrDefault(productId, 0L); + productRepository.updateLikeCount(productId, likeCount); + } + } + + @Test + @DisplayName("동일한 상품에 대해 여러명이 좋아요를 요청해도, 상품의 좋아요 개수가 정상 반영되어야 한다") + void concurrencyTest_likeShouldBeProperlyCounted() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + + int userCount = 10; + List users = new ArrayList<>(); + for (int i = 0; i < userCount; i++) { + users.add(createAndSaveUser("user" + i, "user" + i + "@example.com", 0L)); + } + + ExecutorService executorService = Executors.newFixedThreadPool(userCount); + CountDownLatch latch = new CountDownLatch(userCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (User user : users) { + executorService.submit(() -> { + try { + likeFacade.addLike(user.getUserId(), productId); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + long actualLikesCount = likeRepository.countByProductIds(List.of(productId)) + .getOrDefault(productId, 0L); + + assertThat(actualLikesCount).isEqualTo(userCount); + assertThat(successCount.get()).isEqualTo(userCount); + assertThat(exceptions).isEmpty(); + } + + @Test + @DisplayName("동일한 사용자가 동시에 여러번 좋아요를 요청해도, 정상적으로 카운트되어야 한다") + void concurrencyTest_sameUserMultipleRequests_shouldBeCountedCorrectly() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + User user = createAndSaveUser("testuser", "test@example.com", 0L); + String userId = user.getUserId(); + + int concurrentRequestCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); + CountDownLatch latch = new CountDownLatch(concurrentRequestCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < concurrentRequestCount; i++) { + executorService.submit(() -> { + try { + likeFacade.addLike(userId, productId); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + // UNIQUE 제약조건으로 인해 정확히 1개의 좋아요만 저장되어야 함 + long actualLikesCount = likeRepository.countByProductIds(List.of(productId)) + .getOrDefault(productId, 0L); + + assertThat(actualLikesCount).isEqualTo(1L); + // 애플리케이션 레벨 체크 또는 데이터베이스 UNIQUE 제약조건으로 인해 + // 모든 요청이 성공하거나 일부는 예외가 발생할 수 있지만, + // 최종적으로는 1개의 좋아요만 저장되어야 함 + assertThat(successCount.get() + exceptions.size()).isEqualTo(concurrentRequestCount); + } + + @Test + @DisplayName("@Transactional(readOnly = true)와 UNIQUE 제약조건은 서로 다른 목적을 가진다") + void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurposes() throws InterruptedException { + // 이 테스트는 @Transactional(readOnly = true)와 UNIQUE 제약조건의 차이를 보여줍니다. + // + // UNIQUE 제약조건: + // - 목적: 데이터 무결성 보장 (중복 데이터 방지) + // - 예시: LikeFacade.addLike()에서 동일 사용자가 동일 상품에 중복 좋아요 방지 + // - 작동: 데이터베이스 레벨에서 물리적으로 중복 삽입 방지 + // + // @Transactional(readOnly = true): + // - 목적: 여러 쿼리 간의 논리적 일관성 보장 + // - 예시: LikeFacade.getLikedProducts()에서 좋아요 목록과 집계 결과의 일관성 + // - 작동: 모든 쿼리가 동일한 트랜잭션 내에서 실행되어 일관된 스냅샷을 봄 + // + // REPEATABLE READ 격리 수준에서: + // - 트랜잭션이 없으면: 각 쿼리가 독립적으로 실행되며, 각 쿼리는 자체 스냅샷을 봄 + // - 트랜잭션이 있으면: 모든 쿼리가 동일한 트랜잭션 시작 시점의 스냅샷을 봄 + // + // 실제 문제 시나리오: + // 1. 좋아요 목록 조회 (쿼리 1) - 시점 T1의 스냅샷 + // 2. 다른 트랜잭션이 좋아요 추가 (커밋) + // 3. 좋아요 수 집계 (쿼리 2) - 시점 T2의 스냅샷 (T1과 다를 수 있음) + // + // 트랜잭션이 없으면: + // - 쿼리 1과 쿼리 2가 서로 다른 시점의 스냅샷을 볼 수 있음 + // - 좋아요 목록에는 상품1이 1개로 보이지만, 집계 결과는 2개일 수 있음 + // + // 트랜잭션이 있으면: + // - 모든 쿼리가 동일한 시점의 스냅샷을 봄 + // - 좋아요 목록과 집계 결과가 일관됨 + // + // 왜 테스트가 통과하는가? + // - REPEATABLE READ에서는 각 쿼리가 자체적으로 일관된 스냅샷을 봄 + // - 쿼리 실행 시간이 매우 짧아서 다른 트랜잭션이 정확히 중간에 개입할 확률이 낮음 + // - 하지만 여러 쿼리 간의 논리적 일관성을 보장하려면 트랜잭션이 필요함 + + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 100, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 20_000, 100, brand.getId()); + + User user1 = createAndSaveUser("user1", "user1@example.com", 0L); + User user2 = createAndSaveUser("user2", "user2@example.com", 0L); + String userId1 = user1.getUserId(); + String userId2 = user2.getUserId(); + + // user1이 상품1, 상품2에 좋아요를 이미 누른 상태 + likeFacade.addLike(userId1, product1.getId()); + likeFacade.addLike(userId1, product2.getId()); + + ExecutorService executorService = Executors.newFixedThreadPool(20); + CountDownLatch latch = new CountDownLatch(20); + List> allResults = new ArrayList<>(); + + // act + // 여러 스레드에서 동시에 조회를 수행 + for (int i = 0; i < 10; i++) { + executorService.submit(() -> { + try { + List result = likeFacade.getLikedProducts(userId1); + synchronized (allResults) { + allResults.add(result); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + // 다른 스레드들이 조회 중간에 좋아요를 추가/삭제 + for (int i = 0; i < 10; i++) { + final int index = i; + executorService.submit(() -> { + try { + // 조회가 시작된 후 실행되도록 약간의 지연 + Thread.sleep(1 + index); + if (index % 2 == 0) { + // user2가 상품1에 좋아요 추가 + try { + likeFacade.addLike(userId2, product1.getId()); + } catch (Exception e) { + // 이미 좋아요가 있으면 무시 + } + } else { + // user2가 상품2에 좋아요 추가 + try { + likeFacade.addLike(userId2, product2.getId()); + } catch (Exception e) { + // 이미 좋아요가 있으면 무시 + } + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // 좋아요 수 동기화 (비동기 스케줄러를 기다리지 않고 직접 업데이트) + syncLikeCounts(List.of(product1.getId(), product2.getId())); + + // assert + // @Transactional(readOnly = true)가 있으면: + // - 모든 조회가 동일한 트랜잭션 내에서 실행되어 일관된 스냅샷을 봄 + // - 각 조회 결과 내에서 좋아요 목록과 집계 결과가 일관됨 + + // @Transactional(readOnly = true)가 없으면: + // - 각 쿼리가 독립적으로 실행되어 서로 다른 시점의 데이터를 볼 수 있음 + // - 하지만 REPEATABLE READ에서는 각 쿼리가 자체 스냅샷을 보므로 + // 실제로는 문제가 드물 수 있음 + + // 검증: 모든 조회 결과가 정상적으로 반환되었는지 확인 + assertThat(allResults).hasSize(10); + + // 각 조회 결과가 올바른 형식인지 확인 + // 참고: allResults는 동기화 이전에 조회된 결과이므로 likesCount가 0일 수 있습니다. + // 이 테스트는 @Transactional(readOnly = true)의 일관성 보장을 검증하는 것이 목적이므로, + // 동시성 테스트 중 조회된 결과의 상품 ID 일관성만 확인합니다. + for (List result : allResults) { + // user1의 좋아요 목록에는 상품1, 상품2가 포함되어야 함 + List resultProductIds = result.stream() + .map(LikeFacade.LikedProduct::productId) + .sorted() + .toList(); + assertThat(resultProductIds).contains(product1.getId(), product2.getId()); + } + + // 최종 상태 확인 (동기화 후) + List finalResult = likeFacade.getLikedProducts(userId1); + List finalProductIds = finalResult.stream() + .map(LikeFacade.LikedProduct::productId) + .sorted() + .toList(); + assertThat(finalProductIds).containsExactlyInAnyOrder(product1.getId(), product2.getId()); + + // 동기화 후에는 정확한 좋아요 수가 반영되어야 함 + for (LikeFacade.LikedProduct likedProduct : finalResult) { + assertThat(likedProduct.likesCount()).isGreaterThan(0); + } + } +} +