
💡 읽기 전 꼭 확인해주세요!
해당 포스팅은 시리즈로 이어지며, 목록은 다음과 같습니다! :)
동시성 문제를 해결하기 위한 방법으로는 다음과 같이 4가지 방법이 있습니다.
1. Java synchronized
2. Pessimistic Lock
3. Optimistic Lock
4. Distributed Lock
1번 synchronized는 성능 및 로드 밸런싱 환경에서 사용의 한계가 있었기 때문에 필자의 프로젝트에는 적합하지 않는 방식이라고 판단됩니다.
동시성 문제가 발생하는 개소는 자주 발생할 확률이 높기 때문에 이를 2번 방식인 Pessimistic Lock으로 구현하는 것이 가장 합리적이라고 판단됩니다.
분산락은 분산 시스템 내에서 하나의 공유된 락을 통해서 동시성을 전역으로 제어하기 위한 방법입니다.
즉, 서버 분산 환경에서도 각 프로세스들의 원자적인 연산이 가능하도록 보장합니다.
분산락의 종류로 Redis, MySQL, Zookepper 등이 있는데, 이 중에서 Redis를 선택한 이유는 기존에 이미 사용중인 스택이므로, 인프라 구축 부담이 적었습니다.
또한, Redis의 경우 인메모리 DB이므로 성능 측면에서도 상당히 빠르다고 판단하여 Redis에서 사용되는 분산락을 도입할 것을 결정했습니다.
Redis에서 분산락을 구현하기 위해 자주 사용되는 RedisClient로 Lettuce와 Redisson이 있습니다.
Lettuce의 경우, 공식적으로 분산락을 지원하지 않기 때문에 직접 구현하여 사용해야 합니다. Lock을 획득하는 방식으로 락을 획득할 때까지 지속적으로 Redis로 요청을 보내는 SpinLock(스핀락) 방식으로 구현되어 있습니다.
위 방식은 Redis 자체에 많은 부하를 주며, Lock 점유 실패 시 로직을 직접 구현해줘야 한다는 단점이 있습니다.
직접 구현을 해보면 다음과 같습니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class LettuceInventoryFacade {
private final LettuceTemplate lettuceTemplate;
private final ProductService productService;
public void decrease(String productId, Long qty) throws InterruptedException{
// InventoryService 의 decrease 연산 앞 뒤에 락을 걸고 해제하는 코드입니다.
while(!lettuceTemplate.lock(productId)){
Thread.sleep(100);
}
try{
productService.decreaseStock(productId, qty);
}finally{
lettuceTemplate.unlock(productId);
} // 예외와 상관 없이 Lock 반환
}
}
@Test
@DisplayName("Lettuce Distributed Lock 을 통한 동시성 제어")
public void 동시에5000명이주문하는상황Lettuce() throws InterruptedException{
//given
final int threadCount = 5000; // 동시 요청 갯수
ExecutorService executorService = Executors.newFixedThreadPool(30); // 30개의 쓰레드
CountDownLatch countDownLatch = new CountDownLatch(threadCount); // 요청 마친 쓰레드는 대기하도록 처리
//when
for(int i=0; i< threadCount ;i++){
executorService.submit(() -> {
try{
lettuceInventoryFacade.decrease(uuid, 1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally{
countDownLatch.countDown(); // 요청이 들어간 쓰레드는 대기 상태로 전환
}
});
}
countDownLatch.await(); // 모든 쓰레드의 호출이 끝나면 쓰레드 풀 자체 종료
ProductEntity product = productRepository.findByProductId(uuid).orElseThrow();
//then
assertEquals(0, product.getStock());
}
Test결과
Lettuce의 락 획득 방식은 낙관적 락과 비슷하게 스핀락의 형태로 구현되어 있어, 레이스 컨디션이 자주 발생할 것으로 예상되는 로직에서 사용하지 않아야 겠다고 판단했습니다.
Redisson은 락 획득을 위해 pub/sub 방식을 이용합니다.
pub/sub 방식은 락이 해제될 떄마다 subscribe 중인 클라이언트에게 “이제 락 획득을 시도해도 된다” 라는 알림을 보내기 때문에 클라이언트에서 락 획득을 실패 시, redis에 지속적으로 락 획득 요청을 보내는 과정이 사라지고, 이로 인해 훨씬 적은 부하가 발생하게 됩니다.
또한, RLock 이라는 락을 위한 인터페이스를 제공하기 때문에 비교적 쉽게 락을 구현할 수 있습니다.
Lettuce와 비교하기 위해서 다음과 같이 테스트를 해보겠습니다.
implementation 'org.redisson:redisson-spring-boot-starter:3.29.0'
@Slf4j
@Component
@RequiredArgsConstructor
public class RedissonInventoryFacade {
private final RedissonClient redissonClient;
private final ProductService productService;
public void decrease(String productId, Long qty) {
RLock lock = redissonClient.getLock(productId);
try{
boolean available = lock.tryLock(1, 3, TimeUnit.SECONDS);
if(!available){
System.out.println("Lock 획득 실패");
return;
}
productService.decreaseStock(productId,qty);
}catch(InterruptedException e){
throw new RuntimeException(e);
}finally{
lock.unlock();
}
}
}
@Test
@DisplayName("Redisson Distributed Lock 을 통한 동시성 제어")
public void 동시에5000명이주문하는상황Redisson() throws InterruptedException{
//given
final int threadCount = 5000; // 동시 요청 갯수
ExecutorService executorService = Executors.newFixedThreadPool(30); // 30개의 쓰레드
CountDownLatch countDownLatch = new CountDownLatch(threadCount); // 요청 마친 쓰레드는 대기하도록 처리
//when
for(int i=0; i< threadCount ;i++){
executorService.submit(() -> {
try{
redissonInventoryFacade.decrease(uuid, 1L);
} finally{
countDownLatch.countDown(); // 요청이 들어간 쓰레드는 대기 상태로 전환
}
});
}
countDownLatch.await(); // 모든 쓰레드의 호출이 끝나면 쓰레드 풀 자체 종료
ProductEntity product = productRepository.findByProductId(uuid).orElseThrow();
//then
assertEquals(0, product.getStock());
}
Test결과
Redisson 방식으로 테스트한 결과, Lettuce 방식보다 빠를것이라고 생각했지만, 전혀 그렇지 않았습니다. 이러한 테스트를 바탕으로 판단할 경우, Redisson Lock을 사용하는 이유는 DB의 부하를 줄여주기 위해 사용할 수 있습니다.
실제 서비스 시 DB 내 부하를 줄여주기 위해서 Redisson을 구현한 방식으로 구현하였습니다.
성능이 Lettuce와 거의 비슷하게 나오고, DB Lock보다 훨씬 처리 시간이 오래걸리는 이유에 대해서 조금 더 고민해봐야 겠습니다.