이번엔 실제 프로젝트에 락을 적용해보려고한다
비관적 락을 적용해야할지 낙관적 락을 적용해야할지 판단이 잘 서질않는다
과연 이 프로젝트가 실제로 서비스된다면 충돌이 잦을지 적을지 잘 모르겠다
일반적인 상황이라면 충돌이 비교적 적다고 생각되고
특수한 상황 예를들어 선착순 이벤트, 대형 세일 기간 등등.. 이라면 충돌이 많아질것같은데 어떻게 해야할지 모르겠다
일단 비관적 락을 적용하는것은 비교적 간단하니 낙관적 락을 적용하기로 하였다
낙관적 락 적용
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class ProductVariant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "product_variant_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "price", nullable = false)
private int price;
@Column(name = "stock_quantity", nullable = false)
private int stockQuantity;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private ProductStatus status;
@Column(name = "sales_count", nullable = false)
private int salesCount = 0;
@Column(name = "is_default", nullable = false)
private boolean isDefault = false;
@Column(name = "is_active", nullable = false)
private boolean isActive = true;
@Version
private Long version; // 추가
@OneToMany(mappedBy = "productVariant", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProductVariantOption> productVariantOptions = new ArrayList<>();
...
}
먼저 version 필드를 추가해주었다
그리고 재시도를 할 것이냐, 예외를 반환할 것이냐를 선택해야 하는데 재시도를 진행하려고 한다
서비스계층에서 재시도 로직을 포함시키는것이 맞다고 생각하여 한번 작성해보았다
public <T extends CreateOrderRequest> Order createOrderRetry(T request, Member member) throws CustomException {
int retryCount = 0;
boolean success = false;
Order order = null;
while (!success) {
try {
order = createOrder(request, member);
success = true;
} catch (ObjectOptimisticLockingFailureException e) {
retryCount++;
log.info("주문 재시도");
}
}
return order;
}
@Transactional
public <T extends CreateOrderRequest> Order createOrder(T request, Member member) throws CustomException {
@SuppressWarnings("unchecked")
OrderStrategy<T> strategy = (OrderStrategy<T>) orderStrategyProvider.getStrategy(request.getClass());
Address address = addressQueryService.getAddress(request.getAddressId());
Order order = strategy.createOrder(request, member, address);
orderRepository.save(order);
return order;
}
이전과 똑같이 성공할때까지 무한으로 재시도를 하게해보았다
테스트를 한번 해보겠다
Jmeter
이번엔 JUnit 테스트 대신 Apache Jmeter를 사용하여 테스트해보았다
먼저 재시도 로직을 적용하지않고 테스트를 진행해보았다
10명의 사용자가 0.5초안에 요청을 보내도록 설정하고 실행해보았다
한개의 요청만 성공하고 나머지는 실패하였는데 로그를 살펴보니
내가 원한 ObjectOptimisticLockingFailureException
예외가 아닌 MySQLTransactionRollbackException
예외가 발생하였다
Version을 검사하기전에 DB에서 데드락이 먼저 걸려버린것인데 이렇게 되면 테스트가 불가능하기 때문에 고쳐주어야한다...
사실 데드락에 대하여 자세히 알지못한다 서로 다른 트랜잭션에서 서로 필요로하는 데이터의 락을 소유함으로써
무한 대기상태가 발생하는 상황에 발생하는것으로 알고있는데
public <T extends CreateOrderRequest> Order createOrderRetry(T request, Member member) throws CustomException {
int retryCount = 0;
boolean success = false;
Order order = null;
while (!success) {
try {
order = createOrder(request, member);
success = true;
} catch (ObjectOptimisticLockingFailureException e) {
retryCount++;
log.info("주문 재시도 {}", retryCount);
} catch (Exception e) {
retryCount++;
log.info("데드락 재시도 {}", retryCount);
}
}
return order;
}
마음에 들지는 않지만 부족한 나의 지식을 탓하며
일단 급한대로 Exception을 catch해 데드락이라고 생각하고 다시 테스트를 해본다..
Jmeter 에서 10개 요청 모두 성공한걸 볼 수 있고
DB에 재고 0개와 판매량이 10개로 모두 성공한 것을 볼 수 있다
하지만 이렇게 성공할때까지 무한으로 재시도 하는것이 옳은가? 에 대한 답을 잘 모르겠다
실무에선 어떤 방식으로 하는지 모르겠다
무작정 재시도를 하기보단 재시도 제한 횟수를 정해놓고 횟수가 초과되면
사용자에게 다시 요청해달라는 메세지를 보내는 방향으로 생각해보았다
public <T extends CreateOrderRequest> Order createOrderRetry(T request, Member member) throws CustomException {
final int MAX_RETRY = 3;
int retryCount = 0;
boolean success = false;
Order order = null;
while (!success) {
try {
order = createOrder(request, member);
success = true;
} catch (ObjectOptimisticLockingFailureException e) {
retryCount++;
log.info("주문 재시도 {}", retryCount);
} catch (Exception e) {
retryCount++;
log.info("데드락 재시도 {}", retryCount);
}
}
if (!success)
throw new CustomException(ErrorCode.PLEASE_RETRY);
return order;
}
이렇게 최대 3번까지 재시도를 하고 횟수를 초과하면 500 에러와함께 다시 요청해달라는 메시지를 보내기로하였다
이번엔 3건의 요청만 성공하고 나머지 7건은 성공하지 못하였다
예외도 잘 응답된다
좀 더 찾아보던중 Spring Retry 라이브러리로 간편하게 재시도를 요청할수있다는것을 알게되었다
Spring Retry도 사용해보았다
Spring Retry
implementation 'org.springframework.retry:spring-retry'
의존성을 추가해주고
@EnableRetry
public class CoupangApplication {
public static void main(String[] args) {
SpringApplication.run(CoupangApplication.class, args);
}
}
@EnableRetry 어노테이션을 추가해 활성해주면된다
@Retryable(
value = {
ObjectOptimisticLockingFailureException.class,
CannotAcquireLockException.class,
},
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
@Transactional
public <T extends CreateOrderRequest> Order createOrder(T request, Member member) throws CustomException {
@SuppressWarnings("unchecked")
OrderStrategy<T> strategy = (OrderStrategy<T>) orderStrategyProvider.getStrategy(request.getClass());
Address address = addressQueryService.getAddress(request.getAddressId());
Order order = strategy.createOrder(request, member, address);
orderRepository.save(order);
return order;
}
@Recover
public <T extends CreateOrderRequest> Order recover() throws CustomException {
throw new CustomException(ErrorCode.PLEASE_RETRY);
}
코드를 이렇게 개선할 수 있다
value = {}에 어떤 예외가 발생하면 재시도를 할것인지 적어주면되고
maxAttems에는 최대 재시도 횟수를 적용할 수 있다
backoff에는 재시도 간격을 설정해줄 수 있다
지금은 100ms 간격을 두고 재시도를 하도록 하였다
CannotAcquireLockException.class
은 데드락 관련 예외라고 한다
낙관적 락 예외나 데드락 예외가 발생하면 재시도를 하게되고
만약 최대 재시도 횟수만큼 시도해도 실패한다면
@Recover 어노테이션이 붙은 메서드를 실행하게된다
여기선 이전과 똑같이 다시 시도해달라는 응답을 반환하도록 한다
다시 테스트 해보았는데 이전과 결과는 똑같다
간격을 설정해주면 조금이라도 성공률을 높여주지 않을까 생각했는데 별로 의미가 없던 것같다