Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] 주문 시점에 Cart에 낙관락을 추가한 버전 #146

Closed
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ dependencies {
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

// Retryable
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'

// actuator
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/camp/woowak/lab/LabApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication
@EnableJpaAuditing
@EnableRetry
public class LabApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public enum CartErrorCode implements ErrorCode {
MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "ca_1_1", "해당 메뉴가 존재하지 않습니다."),
OTHER_STORE_MENU(HttpStatus.BAD_REQUEST, "ca_1_2", "다른 매장의 메뉴는 등록할 수 없습니다."),
STORE_NOT_OPEN(HttpStatus.BAD_REQUEST, "ca_1_3", "주문 가능한 시간이 아닙니다."),
NOT_FOUND(HttpStatus.BAD_REQUEST, "ca_2_1", "카트에 담긴 상품이 없습니다.");
NOT_FOUND(HttpStatus.BAD_REQUEST, "ca_2_1", "카트에 담긴 상품이 없습니다."),
RAPID_ADD_MENU(HttpStatus.BAD_REQUEST, "ca_3_1", "잠시 후 다시 요청해주세요.");

private final int status;
private final String errorCode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package camp.woowak.lab.cart.exception;

import camp.woowak.lab.common.exception.BadRequestException;

public class RapidAddCartException extends BadRequestException {
public RapidAddCartException(String message) {
super(CartErrorCode.RAPID_ADD_MENU, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

Expand All @@ -26,6 +27,8 @@ public class CartEntity {
private UUID customerId;
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true)
private List<CartItemEntity> cartItems;
@Version
private long version;

public Cart toDomain() {
List<CartItem> cartItems = this.cartItems.stream().map(CartItemEntity::toDomain).collect(Collectors.toList());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import java.util.UUID;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;

import camp.woowak.lab.cart.persistence.jpa.entity.CartEntity;
import jakarta.persistence.LockModeType;

public interface CartEntityRepository extends JpaRepository<CartEntity, Long> {
@Lock(LockModeType.OPTIMISTIC)
Optional<CartEntity> findByCustomerId(UUID customerId);
}
8 changes: 8 additions & 0 deletions src/main/java/camp/woowak/lab/cart/service/CartService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import java.util.List;

import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -30,6 +33,11 @@ public CartService(CartRepository cartRepository, MenuRepository menuRepository)
* @throws camp.woowak.lab.cart.exception.OtherStoreMenuException 다른 가게의 메뉴를 담은 경우 도메인에서 발생
* @throws camp.woowak.lab.cart.exception.StoreNotOpenException 해당 가게가 열려있지 않은 경우 발생
*/
@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2, random = true)
)
public void addMenu(AddCartCommand command) {
Cart customerCart = getCart(command.customerId());

Expand Down
31 changes: 31 additions & 0 deletions src/main/java/camp/woowak/lab/common/aop/CartTransactionAop.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package camp.woowak.lab.common.aop;

import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;

import camp.woowak.lab.cart.exception.RapidAddCartException;
import camp.woowak.lab.order.exception.DuplicatedOrderException;

@Aspect
@Component
@Order(1)
public class CartTransactionAop {
private static final Logger log = LoggerFactory.getLogger(CartTransactionAop.class);

@AfterThrowing(pointcut = "execution(* camp.woowak.lab.cart.service.CartService..*(..))", throwing = "ex")
public void afterThrowingCart(ObjectOptimisticLockingFailureException ex) {
log.info("카트에 상품을 담기 위한 낙관적 락을 획득하지 못했습니다.", ex);
throw new RapidAddCartException("카트에 상품을 담기 위한 낙관적 락 획득에 실패했습니다.");
}

@AfterThrowing(pointcut = "execution(* camp.woowak.lab.order.service.OrderCreationService..*(..))", throwing = "ex")
public void afterThrowingOrder(ObjectOptimisticLockingFailureException ex) {
log.info("주문/결제를 하기 위한 낙관적 락을 획득하지 못했습니다.", ex);
throw new DuplicatedOrderException("결제를 위한 낙관적 락 획득에 실패했습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;

import camp.woowak.lab.cart.domain.Cart;
import camp.woowak.lab.cart.repository.CartRepository;

@DataJpaTest
@Transactional
class JpaCartRepositoryTest {
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private CartRepository cartRepository;
@Autowired
Expand Down Expand Up @@ -51,7 +52,8 @@ class FindByCustomerIdIs {
@Test
@DisplayName("저장된 CartEntity가 있는 경우")
void returnOptionalWhenCartEntityIsFound() {
Optional<Cart> findCart = cartRepository.findByCustomerId(fakeCustomerId.toString());
Optional<Cart> findCart = transactionTemplate.execute(
(status) -> cartRepository.findByCustomerId(fakeCustomerId.toString()));
assertThat(findCart.isPresent()).isTrue();
assertThat(findCart.get().getCustomerId()).isEqualTo(fakeCustomerId.toString());
}
Expand All @@ -60,7 +62,8 @@ void returnOptionalWhenCartEntityIsFound() {
@DisplayName("저장된 CartEntity가 없는 경우")
void returnEmptyOptionalWhenCartEntityIsNotFound() {
cartEntityRepository.deleteAll();
Optional<Cart> findCart = cartRepository.findByCustomerId(fakeCustomerId.toString());
Optional<Cart> findCart = transactionTemplate.execute(
(status) -> cartRepository.findByCustomerId(fakeCustomerId.toString()));
assertThat(findCart.isPresent()).isFalse();
}
}
Expand All @@ -80,8 +83,7 @@ void duplicateCustomerId() {
void success() {
UUID newFakeCustomerId = UUID.randomUUID();
Cart save = cartRepository.save(new Cart(newFakeCustomerId.toString()));

assertThat(save.getCustomerId()).isEqualTo(newFakeCustomerId.toString());
}
}
}
}