diff --git a/src/main/java/com/f_lab/la_planete/facade/FoodRepositoryFacade.java b/src/main/java/com/f_lab/la_planete/facade/FoodRepositoryFacade.java new file mode 100644 index 00000000..7dd6e9f1 --- /dev/null +++ b/src/main/java/com/f_lab/la_planete/facade/FoodRepositoryFacade.java @@ -0,0 +1,47 @@ +package com.f_lab.la_planete.facade; + +import com.f_lab.la_planete.domain.Food; +import com.f_lab.la_planete.repository.FoodRepository; +import jakarta.persistence.LockTimeoutException; +import jakarta.persistence.PessimisticLockException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FoodRepositoryFacade { + + private static final int MAX_RETRY = 3; + + private final FoodRepository foodRepository; + + public void save(Food food) { + foodRepository.save(food); + } + + public Food findFoodWithLockAndRetry(Long foodId) { + int attempts = 0; + + while (attempts < MAX_RETRY) { + try { + Food food = foodRepository.findFoodByFoodIdWithPessimisticLock(foodId); + + if (food != null) + return food; + + } catch (PessimisticLockException | LockTimeoutException e) { + log.warn("시도 횟수={}, 다시 id={} 에 해당되는 food의 락을 얻기를 시도합니다", attempts, foodId); + } catch (Exception e) { + log.error("Error Occurred at FoodLockFacade.findFoodWithLockAndRetry", e); + throw e; + } + + attempts++; + } + + throw new RuntimeException("현재 너무 많은 요청을 처리하고 있습니다. 다시 시도해주세요"); + } +} diff --git a/src/main/java/com/f_lab/la_planete/service/OrderService.java b/src/main/java/com/f_lab/la_planete/service/OrderService.java index ff445a06..b09a62e4 100644 --- a/src/main/java/com/f_lab/la_planete/service/OrderService.java +++ b/src/main/java/com/f_lab/la_planete/service/OrderService.java @@ -5,7 +5,7 @@ import com.f_lab.la_planete.domain.Payment; import com.f_lab.la_planete.dto.request.OrderCreateRequestDTO; import com.f_lab.la_planete.dto.response.OrderCreateResponseDTO; -import com.f_lab.la_planete.repository.FoodRepository; +import com.f_lab.la_planete.facade.FoodRepositoryFacade; import com.f_lab.la_planete.repository.OrderRepository; import com.f_lab.la_planete.repository.PaymentRepository; import lombok.RequiredArgsConstructor; @@ -23,16 +23,16 @@ public class OrderService { private final OrderRepository orderRepository; - private final FoodRepository foodRepository; + private final FoodRepositoryFacade foodRepositoryFacade; private final PaymentRepository paymentRepository; @Transactional public OrderCreateResponseDTO createFoodOrder(OrderCreateRequestDTO request) { // 음식을 조회 후 요청한 수 만큼 빼기 - Food food = findFoodWithLock(request.getFoodId()); + Food food = foodRepositoryFacade.findFoodWithLockAndRetry(request.getFoodId()); food.minusQuantity(request.getQuantity()); - foodRepository.save(food); + foodRepositoryFacade.save(food); // 주문 생성 후 총 금액 계산 Order order = request.toEntity(food); @@ -51,9 +51,5 @@ public OrderCreateResponseDTO createFoodOrder(OrderCreateRequestDTO request) { return new OrderCreateResponseDTO("CREATED"); } - - private Food findFoodWithLock(Long foodId) { - return foodRepository.findFoodByFoodIdWithPessimisticLock(foodId); - } } diff --git a/src/test/java/com/f_lab/la_planete/facade/FoodRepositoryFacadeTest.java b/src/test/java/com/f_lab/la_planete/facade/FoodRepositoryFacadeTest.java new file mode 100644 index 00000000..e86c367d --- /dev/null +++ b/src/test/java/com/f_lab/la_planete/facade/FoodRepositoryFacadeTest.java @@ -0,0 +1,82 @@ +package com.f_lab.la_planete.facade; + +import com.f_lab.la_planete.domain.Food; +import com.f_lab.la_planete.repository.FoodRepository; +import jakarta.persistence.LockTimeoutException; +import jakarta.persistence.PessimisticLockException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +@SpringBootTest +class FoodRepositoryFacadeTest { + + @InjectMocks + FoodRepositoryFacade foodRepositoryFacade; + @Mock + FoodRepository foodRepository; + + @Test + @DisplayName("락 없이 첫 시도에 성공") + void test_find_food_lock_and_retry_success() { + // given + Long foodId = 1L; + Food expectedFood = createFood(foodId); + + // when + when(foodRepository.findFoodByFoodIdWithPessimisticLock(anyLong())).thenReturn(expectedFood); + Food foundFood = foodRepositoryFacade.findFoodWithLockAndRetry(foodId); + + // then + assertThat(foundFood.getId()).isEqualTo(foodId); + } + + @Test + @DisplayName("첫 번째 시도는 실패하고 두 번째 시도에 성공") + void test_find_food_lock_and_retry_fail_on_first_then_success() { + // given + Long foodId = 1L; + Food expectedFood = createFood(foodId); + + // when + when(foodRepository.findFoodByFoodIdWithPessimisticLock(anyLong())) + .thenThrow(new PessimisticLockException()) + .thenReturn(expectedFood); + + Food foundFood = foodRepositoryFacade.findFoodWithLockAndRetry(foodId); + + // then + assertThat(foundFood.getId()).isEqualTo(foodId); + } + + @Test + @DisplayName("락 타임아웃으로 최대 재시도 후 실패") + void test_find_food_lock_and_retry_fail() { + // given + Long foodId = 1L; + + // when + when(foodRepository.findFoodByFoodIdWithPessimisticLock(anyLong())) + .thenThrow(new PessimisticLockException()) + .thenThrow(new LockTimeoutException()) + .thenThrow(new LockTimeoutException()); + + assertThatThrownBy(() -> foodRepositoryFacade.findFoodWithLockAndRetry(foodId)) + .isInstanceOf(RuntimeException.class) + .hasMessage("현재 너무 많은 요청을 처리하고 있습니다. 다시 시도해주세요"); + } + + + private Food createFood(Long foodId) { + return Food.builder() + .id(foodId) + .build(); + } +} \ No newline at end of file