개요
한정된 수량을 준비된 재고보다 훨씬 많은 유저들이 동시에 구매를 시도할 때, 어떻게 하면 재고수량을 넘지 않게 구매가 완료될 수 있을까??
200개의 재고를 총 10,000명의 유저가 요청했을 때 오버셀링이 일어나지 않아야 한다.
Race Condition
여러 개의 프로세스가 공유 자원에 동시 접근할 때 실행 순서에 따라 결괏값이 달라질 수 있는 현상
A스레드가 구매를 요청하여 3개 남은 재고를 1 감소시키려 한다. 이때 B스레드도 3개 남은 재고를 확인하고 1 감소시키려 DB에 접근한다. 먼저 요청한 A스레드는 3에서 2로 잘 감소시키고 트랜잭션을 마쳤다. 하지만 B스레드는 3개의 재고를 확인하고 1 감소시켰기 때문에 마찬가지로 3에서 2로 감소시키고 재고를 2로 UPDATE 하였다. 3개의 재고를 두 명이 구매하였지만 남은 재고는 2가 되는 기현상이 발생하였다.
이러한 현상이 겹치고 겹쳐 200개의 재고였지만 240, 250명이 구매를 성공하게 돼버렸다.
(쿠팡에서는 위 현상을 잘 제어하여서 작년 가을에 나는 아이폰 15 구매에 실패하였다.)
동시성 문제 해결 방법
Synchronized
- 동시성 제어가 잘 잡혀 오버셀링 일어나지 않았지만, MSA구조로 설계된 나의 프로젝트엔 사용할 수 없었다.
- 추후 트래픽 증가를 고려해서 auto-scaling을 적용할 계획이기 때문에 단일 서버에서 동작하는 Synchronized는 적합하지 않다.
비관적 락
- WRITE Lock으로 동시성 문제를 해결하였지만, 위와 같은 이유로 채택하지 않았다.
- 재고를 감소하는 로직과 재고를 확인하는 로직은 각각 다른 서버에 있기 때문에 데드락이 발생할 가능성이 있다.
Redisson 분산 락
MySql 분산락은 이미 기존에 재고를 관리하는 것에 추가로 부하를 주지 않기 위해 다른 구현 방법을 찾게 되었고, 이전에 사용한 경험이 있는 Redis의 Redisson 분산 락을 적용해 보았다.
public class PayService {
private static final int WAIT_TIME = 1;
private static final int LEASE_TIME = 2;
private static final String STOCK_LOCK = "stock_lock";
@Transactional
public OrderDto prePay(Long userId, Long productId) {
OrderDto dto = null;
RLock lock = redissonClient.getLock(STOCK_LOCK);
try {
if (!lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)) {
log.error("lock획득 시도 실패");
throw new RuntimeException();
}
log.debug("lock 획득");
stockDbClient.decreasePreStock(productId);
dto = orderClient.successOrder(productId, userId, "PREORDER");
} catch (InterruptedException e) {
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("lock 반납");
} else {
log.error("lock 소유하지 않은 스레드");
throw new RuntimeException();
}
}
return dto;
}
}
Lock의 획득 및 해제
재고 감소에 있어 동시성을 제어하고 싶어 처음에는 decreasePreStock(_) 메서드가 끝나고 Lock을 해제하였다.
하지만 그렇게 적용한 결과 successOrder(_) 메서드 안에 한도 초과, 잔액 부족 등으로 결제가 실패할 경우 다시 재고를 증가시키는 로직이 있었기 때문에, 그 부분에서 동시성이 발생하여 재고는 200개에서 0개가 되었지만 구매에 성공한 사람은 193명, 196명, 192명 등으로 불일치하는 현상이 일어났다.
그래서 Lock의 해제 시점을 successOrder(_) 메서드 이후로 설정하였더니 동시성 문제가 잘 해결되었다.
Redis로 재고 관리
Redis의 특성을 살린 분산 락을 구현하다 보니, Redis의 특성을 그대로 재고 시스템에 적용을 하면 어떨지 생각해 보았다.
Redis의 특성으로는
- 빠른 I/O 처리 : 재고의 증감을 빠르게 처리할수록 유저의 불편함은 줄어든다.
- 원자성 보장 : 별도의 Lock처리를 하지 않더라도 단일 스레드로 동작하는 Redis이기 때문에 동시성 문제가 해결된다.
- 커넥션 수와 확장성 : 수천 개의 클라이언트가 동시에 접속할 수 있는 커넥션 수를 지원하고 성능 저하 없이 확장이 가능하다.
가 있다.
그래서 Redis에 key(제품 번호) - value(재고)의 형태로 데이터를 저장하고, 별도의 모듈로 만들어 get(), decrease(), increase() API를 Feign Client로 통신하였다.
public class PayService {
@Transactional
public OrderDto prePay(Long userId, Long productId) {
// 성공한 경우 redis에서 재고를 줄일 것
stockRedisClientClient.decreaseStock(productId);
// 주문상태 성공으로 바꾸기
OrderDto dto = orderClient.successOrder(productId, userId, "PREORDER");
return dto;
}
}
코드의 양도 감소하였으며, 처리 속도도 분산 락에 비해 많이 개선되었다.
JMeter 성능 테스트
10,000건의 구매 요청 결과
결론
동시성을 제어할 수 있는 방법은 여러 가지가 있지만 내가 설계한 구조, 방향성, 계획에 알맞게 효율적인 방식을 적용을 해야 된다.
Lock의 획득/해제 시점이 중요하기 때문에 트랜잭션이 커밋이 되는 시점을 잘 찾아 Lock을 해제해 주자
Redis가 만능은 아니기 때문에 서버가 다운되거나 장애가 일어날 시 대처할 수 있는 방안을 찾아보자(Resilience4j 등)
고려해 볼 것들
- 분산 락을 적용하면서 비동기 방식으로 처리 속도를 줄이고 성능 개선 방안을 찾아보기
- Kafka, RabbitMQ 등 메시지 브로커를 사용하여 비동기 방식 도입
참고
https://helloworld.kurly.com/blog/distributed-redisson-lock/
'멋진 개발자 > 트러블슈팅' 카테고리의 다른 글
트러블 슈팅 - api-gateway에서 jwt 인증/인가 구현 (0) | 2024.03.03 |
---|---|
트러블 슈팅 - RefreshToken을 활용한 보안성 높이기 (0) | 2024.03.03 |
트러블 슈팅 - JavaMailSender 객체로 이메일 인증하기 (0) | 2024.03.03 |