무신사 블랙프라이데이 이벤트를 보고 한정된 수량의 이벤트 상품을 동시에 여러 명의 사용자들이 접근하여 주문을 하면 어떻게 처리할까?
처음 이 프로젝트를 시작했을 때 여러명의 사용자들(= 여러 개의 스레드 or 프로세스)이 이벤트 상품에 접근하면 당연히 race condition이 발생할 것이라 생각했고, 이를 어떤 구조 및 기술을 사용하여 효율적으로 처리하는지에 대해서 공부하고 싶었다.
운영체제에서 배운 개념을 토대로 생각하면 단순히 세마포어나 뮤텍스를 사용하여 Critical Section에 동시에 접근하는 불상사를 막을 수 있다. 하지만 실제 운영환경은 멀티코어 환경에 여러 개의 서버를 가지고 운영하기 때문에 락을 거는 방법을 택하는 순간 성능이 비약적으로 떨어질 것이고, 서비스 운영 자체의 문제가 생길 수도 있다.
특히 블랙프라이데이 이벤트라면 몇 십만, 몇 백만의 유저가 동시접속하기 때문에 이에 대한 대응은 필수이다.
따라서 이런 문제를 실제 운영환경에서는 어떻게 해결하는지에 대해 공부하고 싶어서 해당 프로젝트를 시작했다.
다음은 이 프로젝트의 데이터베이스 스키마이다.
이벤트와 상품이 있고, 이벤트마다 상품이 있을 수 있기 때문에 다대다 관계를 event_product 테이블을 통해 풀어냈다.
event_product의 event_quantity가 이벤트 재고 수량이고, 이 재고에서 race condition이 발생하는 것을 방지하는 방법을 중점적으로 다룰 것이다.
@Transactional
public void getEventProduct(OrderDto.OrderRequest req, Long productId, LocalDateTime currentTime) {
EventProduct eventProduct = eventProductRepository.findEventProductByEventAndProduct(req.getEventId(), productId)
.orElseThrow(() -> new EventProductNotFoundException("해당 이벤트 상품을 찾을 수 없습니다."));
eventProduct.decreaseEventQuantity(req.getQuantity());
}
위의 상황에서 여러 스레드들이 동시에 getEventProduct 함수를 호출하게 되면 event_quantity 자원을 read와 write하는 상황이 발생하게 된다. 이때 race condition이 발생할 수 있는 것이다.
왜냐하면 Transaction 단위로 묶여있기 때문에 event_quantity가 업데이트 되기 전에 다른 스레드에서 event_quantity의 값을 읽었다면 감소하기 전에 값을 읽었을 것이기 때문이다.
테스트 코드를 작성해서 확인해보자.
@Test
void 이벤트_상품_동시성_테스트() throws Exception {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(30);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
OrderDto.OrderRequest req = createOrderRequest(saveEvent.getId());
service.getEventProduct(req, saveProduct.getId(), LocalDateTime.now());
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
EventProduct findEventProduct = eventProductRepository.findById(saveEventProduct.getId()).get();
assertEquals(400, findEventProduct.getEventQuantity());
}
스레드를 100개를 생성해서 getEventProduct() 메서드를 호출해 보았다.
처음 event_quantity를 500으로 설정해 주었고, 스레드 100개로 동시에 호출했으니 400의 값을 기대했지만 실제론 486이라는 값이 나온 것을 확인할 수 있다.
동시성 문제를 해결하기 위해 가장 간단한 방법은 동기 처리를 하는 것이다.
synchronized (this) {
eventProductService.getEventProduct(request, productId, now);
}
위 코드처럼 간단히 synchronized 키워드를 사용하여 해결할 수 있다. 하지만 동기처리를 하게 되면 블프처럼 많은 사용자가 있을 때 시간이 기하급수적으로 올라갈 것이다. 또한 synchronized는 단일 서버에서만 사용 가능한 락이기 때문에 블프같이 서버를 여러 개 사용할 때는 사용할 수 없는 방법이다.
이처럼 단일 서비스에서 동시성 문제를 처리하기 위해서 락을 사용할 수는 있겠지만 서버를 여러 개 사용하는 경우 여러 서버 간 mutual exclution이 필요함. 즉, 락에 대한 정보를 공통적으로 접근할 수 있는 곳에 보관해야 한다는 것 (분산락). 보통 분산락 구현을 Named Lock, Redis, Zookeeper를 사용함.
Redis를 사용하는 방법으로 분산락을 구현해 보자
1. Lettuce
@Component
@RequiredArgsConstructor
@Slf4j
public class LettuceLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(final Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(final Long key) {
return key.toString();
}
}
Lettuce의 setnx 명령어를 통해 분산락을 구현할 수 있는데, setnx에서는 setIfAbsent이 key와 value를 set 할 때 기존에 값이 존재하지 않을 경우에만 set을 해주는 명령어인데, atomic 하게 구현되어 있다.
lock을 획득한 경우는 1을 리턴하고, 획득하지 못하면 0을 리턴하게 되는데, 이를 이용하여 spin lock을 구현할 수 있다.
public void getEventProduct(OrderDto.OrderRequest req, Long productId, LocalDateTime currentTime) throws InterruptedException {
EventProduct eventProduct = eventProductRepository.findEventProductByEventAndProduct(req.getEventId(), productId)
.orElseThrow(() -> new EventProductNotFoundException("해당 이벤트 상품을 찾을 수 없습니다."));
Long key = eventProduct.getId();
//스핀락을 걸어줌
while (!lettuceLockRepository.lock(key)) {
//redis의 부화를 줄여주기 위한 sleep
Thread.sleep(100);
}
try {
//트랜잭션을 안에서 걸어줌
defaultEventProductService.decreaseQuantity(productId, req.getEventId());
} finally {
//unlock
lettuceLockRepository.unlock(key);
}
}
락을 획득한 스레드만 이벤트 상품 재고를 감소시키는 로직을 타고, 나머지 스레드들은 스핀락을 통해 대기하고 있게 된다.
재고를 감소시키고 나면 unlock을 해준다.
위의 테스트 코드 그대로 스레드 100개를 이용하여 테스트한 결과 재고는 알맞게 떨어졌다.
Jmeter를 통해 성능 테스트를 해보자
Redis의 CPU는 7%, Throughput은 초당 10.7개 정도의 성능을 보였다.
하지만 이 방법은 Redis Setnx 공식문서에서 권장하지 않고 해당 명령어가 deprecated 됨. Redisson이나 Lua 스크립트를 추천
2. Redisson
Redis에서 pub/sub 기능을 통해 락을 구현함.
pub/sub 방식은 채널을 하나 만들고, 락을 점유 중인 스레드가 락을 획득하려고 대기 중엔 스레드에게 락을 해제할 때 알려주면 그때 대기 중인 스레드가 락 획득을 시도한다. 따라서 스핀락처럼 재시도를 하지 않음으로써 트래픽을 줄일 수 있다. (스핀락이 항상 비효율적인 것은 아니다.)
RedissonClient는 기본적으로 getLock 함수를 제공하기 때문에 lettuce처럼 직접 구현해 줄 필요가 없다.
public void getEventProduct(OrderDto.OrderRequest req, Long productId, LocalDateTime currentTime) throws InterruptedException {
final String worker = Thread.currentThread().getName();
RLock lock = redissonClient.getLock("redisson_lock p" + productId + "e" + req.getEventId());
try {
//획득시도 시간, 락 점유 시간
if (!lock.tryLock(5, 3, TimeUnit.SECONDS)) {
Thread.currentThread().interrupt();
throw new IllegalStateException("락 획득 실패");
}
defaultEventProductService.decreaseQuantity(productId, req.getEventId());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
try {
lock.unlock();
} catch (Exception e) {
System.out.println("락 반납 오류");
}
}
}
lettuce 방법과 마찬가지로 테스트 코드를 이용하여 테스트한 결과 데이터 정합성이 일치하는 것을 확인하였다.
Redisson을 사용할 때 1초 만에 수행되는 것을 볼 수 있다. lettuce 방법을 사용했을 때 6초 걸린 것에 비하면 엄청난 성능 향상이다.
Jmeter를 이용하여 1000개의 스레드로 요청을 해보자.
Redis CPU 점유율이 약 12% 정도 사용되었고, Throughput은 초당 16.4개로 Lettuce 방법보다 조금 더 높은 처리량을 보여준다.
이상으로 Redis를 사용하여 분산락을 구현하는 방법을 알아보았다.
https://helloworld.kurly.com/blog/distributed-redisson-lock/
풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson
어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.
helloworld.kurly.com
다음에는 좀 더 대용량 처리에 알맞은 구조인 비동기 구조로 설계하는 방법에 대해서 알아보겠다.
'Java, Spring' 카테고리의 다른 글
Spring Security를 이용하여 JWT 토큰방식 로그인 구현 (0) | 2024.08.18 |
---|---|
Java Garbage Collection (0) | 2024.08.08 |
AOP를 이용한 분산락 구현 (0) | 2024.07.21 |
Spring AOP에 대해서 알아보자 (3) | 2024.07.13 |
무신사 블프 이벤트 상품 동시성 문제 (2) (0) | 2024.07.10 |