Redisson을 활용한 동시성 문제 해결

박양원·2024년 1월 12일

Trouble Shooting

목록 보기
1/17
post-thumbnail

Topic

java의 Redis 클라이언트 중 하나인 Redisson을 활용하여 분산락을 적용

Issue

회사에서 서비스 중인 앱에 새롭게 사은품을 포인트로 구매할 수 있는 시스템이 도입되었다. 사은품에는 재고량이 정해져있었기에 멀티 스레드를 기본적으로 사용하는 Spring에서 동시성 문제가 발생할 수 있다고 생각하였다.
이를 확인하고자 코드를 모두 구현한 후 JMeter를 이용하여 부하테스트를 간단하게 진행해보았다.

테스트 조건: Thread 100개

테스트 전 재고 수

테스트 후 재고 수

위 사진을 보면 알 수 있듯이 100개의 Thread, 즉 100 명의 사용자가 동시에 주문 API를 호출했을 때 실제 재고는 26개 밖에 줄어 들지 않았다. (74% 유실)

Step

1. 발생 원인

동시성 문제가 발생하는 원인은 크게 4가지가 있다.

  • 경합 조건
  • 데드락
  • 스타베이션
  • 라이브락

이 중 현재 발생하는 문제는 경합 조건(Race Condition)이 원인으로 파악된다.
(시스템이 다운되지 않았고, 재고 감소와는 달리 주문 내역은 알맞게 100건이 DB에 저장되었음)

2. 어떻게 해결하나?

경합 조건으로 인해 동시성 이슈가 발생하는 경우 낙관적 락, 비관적 락, 분산 락 등 다양한 방법으로 이 문제를 해결할 수 있다. 때마침 프로젝트에 Redis가 사용되고 있기 때문에 Java 진영의 Redis Client 중 하나인 Redisson을 활용하여 분산 락을 적용하여 해결하기로 결정했다.

2-1. 분산 락(Distributed Lock)이란?

  • 락을 획득한 Thread만이 공유된 데이터에 접근할 수 있도록 하는 방식
  • 이 과정에서 공유된 데이터에 대한 동기화를 보장하여 데이터의 원자성을 확보
  • 서버가 분산돼있는 환경에서도 적용 가능

3. Java Redis Client

대표적으로 Jedis, Lettuce와 Redisson 존재한다. 이 중 Jedis는 나머지 두 Client에 비해 상대적으로 부족한 성능과 분산락에 관한 래퍼런스가 없어 고려하지 않고 Lettuce와 Redisson의 장단점을 비교하여 최종적으로 Redisson을 선택하였다.

3-1. Lettuce

  • SpringBoot에서 Redis를 사용하는 경우 기본 Client
  • 동기/비동기 방식을 모두 지원하여 non-blocking하게 요청을 처리할 수 있음
  • 네트워크 통신을 위해 Netty를 사용하여 비동기로 요청을 처리하여 고성능임
  • 확장성이 뛰어남
  • 분산 락을 SETNX 명령어를 사용하여 별도로 구현해야 하며 스핀 락의 형태으로 구현 시 성능 저하가 일어날 수 있음
    • 락 요청의 만료 시간을 제공하지 않아 다른 서버가 락을 점유할 수 없는 문제가 발생함
    • 락의 점유 실패 시, 무한히 Redis에게 Request를 보냄

3-2. Redisson

  • 기본 Client가 아니므로 별도의 의존성을 추가해야 함
  • Lettuce와 달리 Pub/Sub 방식을 사용하여 해당 락을 subscribe하는 Client들에게 락의 해제 알림을 줌
    => 무한히 Request를 보내지 않아 Redis의 부하가 감소
  • RLock이라는 자체 클래스를 제공하여 분산락을 간편히 사용할 수 있음
  • Lua 스크립트를 사용
    • 명령어를 하나의 스크립트로 결합하여 실행하여 네트워크 트래픽이 감소
    • 서버 측에서 계산 처리 후 클라이언트에 결과를 반환하여 데이터 전송 시간이 감소
  • java.util.concurrent.locks.Lock 인터페이스를 구현하고 있어 분산 환경에서도 일반 Java 락처럼 사용 가능
  • Pub/Sub시스템을 사용하기에 추가적인 CPU 및 메모리 자원을 소모함
  • 간편히 사용할 수 있는 대신, 내부적으로 구현된 복잡한 로직을 완전히 이해하고 사용하기가 어려움

4. RLock을 활용한 분산락 적용

프로젝트에 이미 Redisson 관련 설정이 모두 적용돼있기에 구성 환경 설정은 개인 프로젝트에서 정리 후 재포스팅 예정

기존 코드에 RLock을 적용하여 재구현해보았다.

    @Transactional(rollbackFor = Exception.class)
    public Long makeOrder(Long memberId, Long itemId, OrderInfoAndDeliveryHistoryDTO.SaveRequest request) {
        log.info("MAKE ORDER :: {}", itemId);

        RLock lock = acquireLock(itemId);

        try {
            return performOrderTransaction(memberId, itemId, request);
        } finally {
            releaseLock(lock);
        }
    }
    
    private RLock acquireLock(Long itemId) {
    	RLock lock = redissonClient.getLock("itemLock:" + itemId);

        try {
            if (!lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                log.error("COULD NOT ACQUIRE A LOCK FOR ITEM :: {}", itemId);
                throw new RuntimeException("COULD NOT ACQUIRE A LOCK FOR ITEM :: " + itemId);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("LOCK ACQUISITION INTERRUPTED", e);
            throw new RuntimeException("LOCK ACQUISITION INTERRUPTED", e);
        }

        return lock;
    }

5. 적용 후 결과

최종적으로 아래와 같이 데이터 유실없이 재고 감소가 Thread의 수인 100만큼 알맞게 줄어든 것을 확인하였다.

분산락 테스트 전 재고수

분산락 테스트 후 재고수

Reflection

결과는 대만족이었으나 찝찝함이 많이 남았다. 앞서 얘기했던 것처럼 Redis에 관한 설정은 기존에 있는 것을 그대로 사용하였고, 아직 Redisson 내부적으로 RLock이 어떤식으로 동작하는지 깊이있게 알지 못하기에 이 부분을 더 공부하여 재정리한 포스팅을 작성해보아야겠다.

0개의 댓글