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

박양원·2024년 1월 12일
0

Trouble Shooting

목록 보기
1/16
post-thumbnail
post-custom-banner

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이 어떤식으로 동작하는지 깊이있게 알지 못하기에 이 부분을 더 공부하여 재정리한 포스팅을 작성해보아야겠다.

post-custom-banner

0개의 댓글