4주 간 예약 구매 사이드 프로젝트를 진행했습니다. `예약 구매` 서비스는 특정 기념일이나 타임세일과 같이 해당 기간 또는 시간에만 상품을 사용자들에게 노출하고, 판매하는 이미 많은 브랜드에서 마케팅 전략으로 사용되고 있는 서비스입니다.
E-COMMERCE 관련 백엔드 API 개발을 진행하면서 처음으로 멀티 쓰레드 환경에서 테스트를 경험해보았고, 재고 관리 기능을 구현하면서 Race-Condition 문제를 접했습니다. 예약 구매 비지니스 로직의 대한 간단한 이해를 시작으로, 동시성 제어, 적용한 방법의 한계에 대한 내용까지 글을 작성해보려고 합니다.
개발 방법
예약 구매 비즈니스 로직에 대한 간단한 이해
유저가 주문서비스의 주문 API를 호출하면 먼저 유저의 정보와 상품 번호 구입 수량 정보가 주문 내역 DB에 저장됩니다. 이후 결제 서비스에 진입하여 주문 내역 DB 정보를 바탕으로 재고량 계산, 결제 금액 계산을 하는데요. 결제 API를 호출한 유저번호에 해당하는 주문 내역 DB row를 조회하여 실제 재고량 계산에 필요한 상품번호, 구입수량 DTO로 변환하는 과정을 거치게 됩니다. 그 다음 상품번호와 일치하는 재고량 DB를 조회하여 재고량을 계산해주는 플로우로 재고 관리가 이뤄지게 됩니다. (결제 금액 또한 동일한 플로우로 진행되니 관련 내용은 생략하겠습니다.)
개발 방법
재고 관리 기능에서의 동시성 이슈
재고량 DB는 사용자의 요청에 의해 매우 빈번하게 업데이트가 되는 DB입니다. 단일 서버에서 운영되고 1명의 사용자만 사용하는 서비스면 동시성 이슈를 생각하지 않아도 상관없겠지만 반대의 상황(여러대의 서버 여러명의 사용자)에서는 시스템의 안정성과 직결되는 부분이 동시성 이슈기 때문에 재고량 증가와 감소에 따른 적절한 재고량 처리 방안을 고려해야 했습니다.
저는 먼저 적절하게 동시성 제어가 안되었을 때 어떤 문제가 있을지 살펴보기 위해 구매 서비스로 0.01초 사이를 두고 접근하는 트랜잭션이 2개가 있다고 가정을하고 시나리오를 생각해보았습니다.
트랜잭션 1에서는 현재 공연 티켓 재고인 10개에 1개 구매에 대한 재고를 차감해 9개로 갱신을 할 것이고, 트랜잭션 2에서는 트랜잭션 1에서 요청이 있었는 알 수있는 방법이 없으므로 처음에 조회한 10개에 다시 1개를 차감하여 9개라는 엉뚱한 값으로 재고를 갱신 할 것입니다.
실제로 예제 코드를 통해 해당 시나리오를 테스트 해봤습니다.
가설1. 100개의 재고에 대한 100명의 사용자 요청은(1인 1개) Race Condition으로 인해 0개가 되지 않을 것이다.
예제 코드
상품 엔티티
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false)
private Integer price;
@Column(nullable = false, length = 1000)
private String description;
@Enumerated(EnumType.STRING)
private ProductType productType;
@OneToOne(optional = false, cascade = CascadeType.ALL)
@JoinColumn(name = "stock_id", nullable = false)
private Stock stock;
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
private final Set<Orders> orders = new LinkedHashSet<>();
private Product(String name, Integer price, String description, ProductType productType, Stock stock) {
this.name = name;
this.price = price;
this.description = description;
this.productType = productType;
this.stock = stock;
}
public static Product of(String name, Integer price, String description, ProductType productType, Stock stock) {
return new Product(name, price, description, productType, stock);
}
public void purchase(final Integer quantity) {
stock.decrease(quantity);
}
public void cancel(final Integer quantity) {
stock.increase(quantity);
}
}
재고 엔티티
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Integer remain;
private Stock(Integer remain) {
this.remain = remain;
}
public static Stock of(Integer remain) {
return new Stock(remain);
}
public void decrease(final Integer quantity) {
if ((remain - quantity) < 0) {
throw new IllegalArgumentException();
}
remain -= quantity;
}
public void increase(final Integer quantity) {
remain += quantity;
}
}
구매 서비스 비즈니스 로직과 테스트 코드
@Transactional
public void purchase(Long productId, Integer quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(IllegalArgumentException::new);
product.purchase(quantity);
}
@Test
void 동시에_100명이_티켓을_구매한다() throws InterruptedException {
Long productId = productRepository.save(Product.of("티켓", 36_000, "공연 티켓", ProductType.NOT_RESERVATION, Stock.of(100)))
.getId();
ExecutorService executorService = Executors.newFixedThreadPool(100);
CountDownLatch countDownLatch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
executorService.submit(() -> {
try {
productService.purchase(productId, 1);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
Product actual = productRepository.findById(productId)
.orElseThrow();
assertThat(actual.getStock().getRemain()).isZero();
}
테스트 코드를 실행해보면 0을 기대했지만 실제 재고량은 84인것을 확인할 수 있습니다. 즉 84개의 재고에 대한 갱신 유실이 발생한 것이고 한 트랙잭션이 커밋되기 전 다른 트랜잭션이 값을 읽어 버려서 생긴 문제입니다.
저는 이 문제를 해결하기 위해 다음과 같은 가설을 세웠습니다.
가설2. 데이터에 1개의 쓰레드만 접근 가능하게 하면 될 것이다.
그리고 자바에서 지원하는 `synchronized`를 통해 문제를 해결해보려고 시도했습니다. ('@Transactional' 과 'sysnchronized' 를 같이 사용할 시 Spring AOP 로 인해 동기화 문제가 발생할 수 있습니다.)
개발 방법
synchronized를 통한 재고 반영과 테스트
public synchronized void purchase(Long productId, Integer quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(IllegalArgumentException::new);
product.purchase(quantity);
productRepository.save(product);
}
@Test
//테스트는 동일합니다
100개의 쓰레드에 대한 동시성 이슈는 해결 되었지만 synchronized 가 실제 운영환경에서 근본적인 해결책이 될 수 없다고 합니다. 음 어떤 시나리오를 그려야 할지 고민이 생겼고 synchronized의 특성을 이해하고 다른 방법을 고려해야 했습니다.
synchronized 원리
https://docs.oracle.com/javase/tutorial/essential/concurrency/syncmeth.html
자바의 synchronized는 하나의 프로세스 안에서만 보장이 됩니다. 서버가 1대일때는 데이터의 접근을 서버1개가 해서 괜찮겠지만 서버가 2대 혹은 그 이상일 경우 데이터의 접근을 여러곳에서 할 수 있게 됩니다. 실무에서는 후자의 경우로 대부분 운영하기 때문에 결국 처음에 그려본 `사진1. 트랜잭션 2개의 동시성 시나리오` 와 비슷한 문제가 발생하게 됩니다.
개발 방법
낙관적 락 (Optimistic Lock) vs 비관적 락 (Pessimistic Lock)
다중 서버 환경에서 동시성 제어를 하기 위해 기존에 사용하고 있던 JPA에서 제공하는 @Lock 어노테이션을 활용하기로 결정했습니다. 그리고 `예약 구매` 서비스 특성상 정해진 시간에 트래픽으로 인해 반드시 동시성 이슈가 발생하기 때문에 비관적 락을 사용하는게 맞다라고 판단하였습니다. 락을 통한 동시성 이슈 해결 시나리오를 생각해보면 다음과 같습니다.
예제 코드
비관적 락(Pessimistic Lock) 설정
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.productId = :productId")
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="5000")})
Stock findByIdWithPessimisticLock(Long productId);
}
여기서 갱신하는 데이터에 대해서만 Lock을 설정해야 데드락과 시스템 성능 저하를 예방할 수 있는데 구매 API의 경우 재고를 갱신하는 이벤트가 메인 비즈니스 로직이기 때문에 현재 재고 데이터에 대해서만 고유하게 Row Locking이 걸리도록 테이블 관계를 수정 후 구현했습니다.
구매 서비스 Lock 적용 및 테스트 코드
@Transactional
public void purchase(Long productId, Integer quantity) {
Stock stock = stockRepository.findByIdWithPessimisticLock(productId);
stock.purchase(quantity);
}
@Test
void 동시에_100명이_티켓을_구매한다() throws InterruptedException {
Long productId = productRepository.save(Product.of("티켓", 36_000, "공연 티켓", ProductType.NOT_RESERVATION, Stock.of(100)))
.getId();
ExecutorService executorService = Executors.newFixedThreadPool(100);
CountDownLatch countDownLatch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
executorService.submit(() -> {
try {
stockService.purchase(productId, 1);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
Stock actual = stockRepository.findByProductId(productId)
.orElseThrow();
assertThat(actual.getStock().getRemain()).isZero();
}
비관적 락(Pessimistic Lock)의 한계와 TODO
100개의 쓰레드에 대한 동시성 이슈는 해결 되었습니다. 그렇다면 비관적 락을 사용하는게 만능일까? 라는 의문이 생겼습니다. 물론 동시성은 확실히 보장할 수는 있지만 비관적 락 또한 요청이 BLOKING 되어 서비스 성능이 저하가 될 수 있다는점, 남용하게 되면 데드락이 발생할 수 있다는 점에서 단점들이 존재했고 저는 또 다른 해결 방안인 `분산락`을 학습하고 적용하여 문제를 다시 해결하려고 해봤습니다. 분산락의 적용기는 다음 글에서 다루겠습니다. 감사합니다.
'SideProject' 카테고리의 다른 글
스위프 5기 FADE 프로젝트 돌아보기 후기 (0) | 2024.08.12 |
---|---|
Sse 알림 기능 적용해보기 (Spring Boot) (0) | 2024.06.14 |
Redis Cache 적용해서 Tps를 높여보기 (2) | 2024.03.05 |
구매 서비스에서 재고 동시성 이슈 해결해보기(2) - Redisson (1) | 2024.02.28 |