안녕하세요 간만에 글을 남기네요
요즘 스터디에서 과제를 풀고 있는데 이제까지 진행상황을 공유할까 합니다.
소스 위주로 갈 예정이고 아래는 실행환경 입니다.
언어 : java
프레임워크 : 스프링부트 3.0, 스프링6, Spring-Data-JPA
툴 : intellij
엔티티 구성부터~ 서비스 로직까지 보여주고, 어떻게 테스트 했는지 공유 하겠습니다.
결론부터 말씀 드리면,
어플레케이션 낙관적 락과 비관적 락, DB 레코드 락, DB 낙관적 락(조건) 등을 테스트 하는데,
비관적 락과 DB 레코드 락, DB 낙관적 락(조건)은 모두 성공했지만, 낙관적 락은 Entity에 @Version 컬럼을 추가해서 테스트 해 보았지만
리트라이 조건을 제대로 하지 않아서 그런지 계속 실패 했습니다.
나중에 성공하면 이부분은 공유 하겠습니다.
아래는 소스 이니 참고 부탁드립니다.
감사합니다.
package com.kyy.placeorderproject.domain;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class ProductEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int stock;
@Version
private int version;
public void decreaseStock(int quantity) {
if (this.stock <= 0) {
throw new RuntimeException("Sold Out");
}
this.stock -= quantity;
}
}
package com.kyy.placeorderproject.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class OrderEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private int quantity;
}
package com.kyy.placeorderproject.service;
import com.kyy.placeorderproject.domain.OrderEntity;
import com.kyy.placeorderproject.domain.ProductEntity;
import com.kyy.placeorderproject.repository.OrderRepository;
import com.kyy.placeorderproject.repository.ProductRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.OptimisticLockException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class OrderService {
@Autowired
private ProductRepository productRepository;
@Autowired
private OrderRepository orderRepository;
final private EntityManager entityManager; // 트랜잭션 단위로~~~
@Transactional
public boolean decreaseStock(OrderEntity order) {
// 낙관적을 DB 조건으로 묶어서 호출 성공!
int updatedRows = productRepository.decreaseStockByAmount(order.getProductId(), order.getQuantity());
if (updatedRows == 0) {
// 재고 감소 실패 (재고가 부족하거나 다른 조건 미충족)
throw new RuntimeException("재고 감소 실패");
}
orderRepository.save(order);
return true;
}
@Transactional
public void placeOrder(OrderEntity order) {
// race condition - 비관적락은 성공! - JPA 기능으로 구현
// Pessimistic Locking:
// ProductEntity productEntity = entityManager.find(ProductEntity.class, order.getProductId(), LockModeType.PESSIMISTIC_WRITE);
// SELECT FOR UPDATE 성공! - DB 기능으로 구현
ProductEntity productEntity = productRepository.findProductForUpdate(order.getProductId());
// 재고 감소
productEntity.decreaseStock(order.getQuantity());
productRepository.save(productEntity);
// 주문 생성
orderRepository.save(order);
System.out.println("productEntity.getStock() = " + productEntity.getStock());
System.out.println("order.getQuantity() = " + order.getQuantity());
}
private static final int MAX_RETRIES = 3; // 최대 재시도 횟수
private static final long RETRY_DELAY_MS = 100; // 재시도 대기 시간 (밀리초)
@Transactional
public void placeOrderWithRetry(OrderEntity order) {
int retries = 0;
while (retries < MAX_RETRIES) {
try {
placeOrderVersion(order);
return; // 성공적으로 주문 처리가 완료되면 메서드 종료
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
if (++retries < MAX_RETRIES) {
// 재시도 전에 잠시 대기
try {
Thread.sleep(RETRY_DELAY_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
} else {
// 최대 재시도 횟수를 초과하면 예외를 다시 던짐
throw e;
}
}
}
}
public void placeOrderVersion(OrderEntity order) {
// race condition - 낙관적락은 실패! - @Version으로 해봤지만 안됨, 재시도 로직 없음
// Optimistic Locking
ProductEntity productEntity = productRepository.findById(order.getProductId()).orElse(null);
// 재고 감소
productEntity.decreaseStock(order.getQuantity());
productRepository.save(productEntity);
// 주문 생성
orderRepository.save(order);
System.out.println("productEntity.getStock() = " + productEntity.getStock());
System.out.println("order.getQuantity() = " + order.getQuantity());
}
@Transactional
public void decreaseStockRetry(OrderEntity order) {
try {
// 재고 조회
ProductEntity product = entityManager.find(ProductEntity.class, order.getProductId(), LockModeType.PESSIMISTIC_WRITE);
if (product.getStock() <= 0) {
throw new IllegalArgumentException("No stock left");
}
// 재고 감소
product.decreaseStock(order.getQuantity());
productRepository.save(product);
// 주문 생성
orderRepository.save(order);
} catch (OptimisticLockException e) {
e.getStackTrace();
}
}
}
package com.kyy.placeorderproject.service;
import com.kyy.placeorderproject.domain.OrderEntity;
import com.kyy.placeorderproject.domain.ProductEntity;
import com.kyy.placeorderproject.repository.ProductRepository;
import jakarta.transaction.Transactional;
import org.aspectj.lang.annotation.Before;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
class OrderEntityServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
@BeforeEach
public void init() {
productRepository.deleteAll();
// 초기 제품 생성
ProductEntity product = new ProductEntity();
product.setId(1L);
product.setName("Product1");
product.setStock(1000);
productRepository.save(product);
}
@Test
@DisplayName("멀티스레드_동시접근_경쟁테스트")
@Order(1)
// @Transactional
public void 멀티스레드_동시접근_경쟁테스트() throws InterruptedException {
ProductEntity product = productRepository.findById(1L).orElse(null);
int numberOfThreads = 100;
int ordersPerThread = 10;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(numberOfThreads);
for (int i = 0; i < numberOfThreads; i++) { // 100
new Thread(() -> {
try {
startLatch.await();
for (int j = 0; j < ordersPerThread; j++) { // 10
// @Version + Retry 넣어도 실패함. ㅠㅠ
orderService.placeOrderWithRetry(new OrderEntity(null, product.getId(), 1)); // 재시도 로직 필요함, 비관적락 테스트 속도 빠름
// 비관적락 테스트 OK
// orderService.placeOrder(new OrderEntity(null, product.getId(), 1)); // 재시도 로직 필요없음, 비관적락 테스트 속도 느림
// 낙관적락 테스트 @Version No OK
// orderService.decreaseStock(new OrderEntity(null, product.getId(), 1));
// Version 낙관적락 테스트
// orderService.versionDecreaseStock(new OrderEntity(null, product.getId(), 1));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
endLatch.countDown();
}
}).start();
}
startLatch.countDown(); // 모든 쓰레드 시작
endLatch.await(); // 모든 쓰레드가 끝날 때까지 대기
ProductEntity finalProduct = productRepository.findById(product.getId()).orElse(null);
assertEquals(0, finalProduct.getStock());
}
@Test
@DisplayName("soldout_재고_테스트")
@Order(2)
public void soldout_재고_테스트() {
// 상품 재고를 10개로 설정
ProductEntity productEntity = new ProductEntity();
productEntity.setName("Product1");
productEntity.setStock(10); // 10일때 실패, 20일때 성공
productRepository.save(productEntity);
// 첫 번째 주문 (10개)
OrderEntity order = new OrderEntity();
order.setProductId(productEntity.getId());
order.setQuantity(10);
orderService.placeOrder(order);
// 두 번째 주문 (5개) - Sold Out 상황
OrderEntity order1 = new OrderEntity();
order1.setProductId(productEntity.getId());
order1.setQuantity(5);
// Sold Out 예외가 발생해야 함
Exception exception = assertThrows(RuntimeException.class, () -> orderService.placeOrder(order1));
// 예외 메시지가 "Sold Out"인지 확인
assertEquals("Sold Out", exception.getMessage());
}
@Test
@DisplayName("데드락_테스트")
@Order(3)
public void 데드락_테스트() throws InterruptedException {
// 초기 제품 생성
ProductEntity productA = new ProductEntity();
productA.setName("Product1");
productA.setStock(1000); // 10일때 실패, 20일때 성공
ProductEntity productB = new ProductEntity();
productA.setName("Product2");
productA.setStock(1000); // 10일때 실패, 20일때 성공
productRepository.save(productA);
productRepository.save(productB);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(2);
// 정방향
Thread thread1 = new Thread(() -> {
try {
startLatch.await();
orderService.placeOrder(new OrderEntity(null, productA.getId(), 1));
Thread.sleep(1000); // 잠시 대기하여 데드락 발생 확률을 높임
orderService.placeOrder(new OrderEntity(null, productB.getId(), 1));
} catch (Exception e) {
e.printStackTrace();
} finally {
endLatch.countDown();
}
});
// 역방향
Thread thread2 = new Thread(() -> {
try {
startLatch.await();
orderService.placeOrder(new OrderEntity(null, productB.getId(), 1));
Thread.sleep(1000); // 잠시 대기하여 데드락 발생 확률을 높임
orderService.placeOrder(new OrderEntity(null, productA.getId(), 1));
} catch (Exception e) {
e.printStackTrace();
} finally {
endLatch.countDown();
}
});
thread1.start();
thread2.start();
startLatch.countDown(); // 모든 쓰레드 시작
endLatch.await(); // 모든 쓰레드가 끝날 때까지 대기
}
}
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |