본문 바로가기

Redis를 사용한 분산락 본문

Dev

Redis를 사용한 분산락

겨울바람_ 2024. 3. 24. 20:52

분산락의 필요성

단 하나의 서버만 존재한다면, 앞서 소개했던 synchronized 만으로도 충분한 동시성 제어가 가능하지만 실제 서비스가 운영되고 있는 운영 서버는 하나만으로는 안정적으로 서비스 운영이 힘들기 때문에 보통 여러 개의 운영 서버를 둔다.

synchronized 는 단 하나의 프로세스에서만 적용되기 때문에 여러 개의 프로세스가 존재하는 상황에서는 원활한 동시성 제어가 불가능하기 때문에 이를 위해 분산락이라는 것이 등장하게 되었다.

Redis를 사용하는 이유

Redis 는 싱글 스레드로 동작하기 때문에, 단일 레디스 노드를 구축해 사용해도 동시성 문제가 발생하지 않는다. MySQL과 Zookeeper를 활용하는 방법을 사용해도 분산락을 구현할 수 있지만 이번 포스팅에서는 우선 Redis를 사용하는 방법에 대해서 알아볼 예정이다.

Java 환경에서의 Redis를 사용한 분산락

Java의 레디스 클라이언트로는 Redisson과 Lettuce가 존재한다. 이 중 Redisson을 사용하는 방법을 알아보자. Redisson과 Lettuce는 락을 사용하는 방식에 여러 차이가 존재한다.

Lettuce로 분산락을 사용하기 위해서는 setnx, setex 등을 이용해 직접 분산락을 구현해야 하지만, Redisson은 Lock Interface를 제공해주기 때문에 개발자가 직접 구현한다는 번거로움이 없고, 락 타임아웃과 같은 설정을 지원해주기 때문에 보다 안전한 사용이 가능하다.

또한 Lettuce는 분산락 구현 시 setnx, setex과 같은 명령어를 이용해 지속적으로 Redis에게 락이 해제되었는지 확인하는 요청을 보내는 스핀락 방식으로 동작한다. 이로 인해 요청이 많아질수록 Redis에 가해지는 부하 또한 증가하게 된다.

반면, Redisson은 Pub/Sub 방식을 이용하기에 락이 해제되면 해당 이벤트를 구독하고 있는 클라이언트에게 신호를 보내고, 신호를 받은 클라이언트는 해당 신호를 받은 직후에 락 획득을 시도하게 된다.

Redisson 적용해보기

Redisson 라이브러리 사용을 위한 의존성 추가

implementation 'org.redisson:redisson-spring-boot-starter:3.27.2'

 

RedissonClient 사용을 위한 Config 설정 파일 또한 생성 후 빈으로 등록한다.

@Configuration
public class RedissonConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        return Redisson.create(config);
    }

}

 

이후 분산락을 효과적으로 사용하기 위해 DistributedLock Annotation 을 작성한다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

    /**
     * 락의 명칭
     */
    String key();

    /**
     * 락의 시간단위
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 락 대기 만료시간
     */
    long waitTime() default 5L;

    /**
     * 락 점유 시간
     */
    long leaseTime() default 3L;
    
}

 

이후 해당 Annotation 사용 시 적용될 AOP를 작성해준다.

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {

    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.example.redisdistributedlock.util.DistributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
        RLock rLock = redissonClient.getLock(key);

        try {
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!available) {
                return false;
            }

            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            try {
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info("Redisson Lock Already UnLock {} {}", method.getName(), key);
            }
        }
    }

}

 

AOP 코드에 대한 분석 이전에 AopForTransaction 과 CustomELParser 에 대해 알아보자. 우선 각 클래스들은 외부 라이브러리 혹은 내장 라이브러리를 사용한 것이 아닌 직접 작성한 클래스다. 

 

 AopForTransaction 클래스는 AOP에서 트랜잭션을 분리하기 위한 목적의 클래스다. 

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
    
}

@Transactional 의 propagation 설정을 REQUIRES_NEW 로 주는 것으로 해당 AOP가 동작할 때 새롭게 트랜잭션을 시작하게 되는 것으로 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하게 만들었다.

 

이로 인해서 독립적인 트랜잭션이 시작되어, 호출된 메소드에서의 작업이 실패하더라도 이전의 트랜잭션에 영향을 미치지 않게 된다. 

 

CustomELParser 클래스는 전달받은 락의 이름을 Spring Expression Language로 파싱하여 읽어온다.  

public class CustomSpringELParser {

    private CustomSpringELParser() {
    }

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }

}

 

해당 분산락을 사용한 동시성 테스트를 위해 Coupon 클래스와 CouponDecreaseService 를 만들어보자

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Coupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private Long availableStock;

    public Coupon(String name, Long availableStock) {
        this.name = name;
        this.availableStock = availableStock;
    }

    public void decrease() {
        validateStockCount();
        this.availableStock -= 1;
    }

    private void validateStockCount() {
        if (availableStock < 1) {
            throw new IllegalArgumentException();
        }
    }

}

Coupon 클래스에는 이용 가능한 수량이 있는지 확인하고 감소시키는 로직이 존재한다. 

@Service
@RequiredArgsConstructor
public class CouponDecreaseService {

    private final CouponRepository couponRepository;

    @Transactional
    public void couponDecrease(Long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow(IllegalArgumentException::new);

        coupon.decrease();
    }

    @DistributedLock(key = "#lockName")
    public void couponDecrease(String lockName, Long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow(IllegalArgumentException::new);

        coupon.decrease();
    }

}

CouponDecreaseService 는 Coupon 의 이용 가능 수량을 감소시키는 로직이 분산락을 사용했을 때와 아닐 때 두 가지로 나뉘어져 있다.

 

위의 CouponDecreaseService 를 활용해 분산락을 활용할 때와 그렇지 않을 때의 테스트를 각각 진행해보자.

우선, 분산락을 사용하지 않았을 때의 테스트 코드다.

    @DisplayName("쿠폰차감_분산락_적용_X_동시성100명_테스트")
    @Test
    void couponDecreaseTestWithoutDistributedLock() throws InterruptedException {
        int numberOfThreads = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.submit(() -> {
                try {
                    couponDecreaseService.couponDecrease(coupon.getId());
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        Coupon persistCoupon = couponRepository.findById(coupon.getId())
                .orElseThrow(IllegalArgumentException::new);

        assertThat(persistCoupon.getAvailableStock()).isZero();
    }

결과는 다음과 같다

쿠폰의 남은 수량이 0이길 바랬지만, 동시성 문제 때문에 90개의 여분 쿠폰이 남게된다. 

 

이번에는 분산락을 적용시킨 로직을 테스트 해보자.

    @DisplayName("쿠폰차감_분산락_적용_동시성100명_테스트")
    @Test
    void couponDecreaseTestWithDistributedLock() throws InterruptedException {
        int numberOfThreads = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.submit(() -> {
                try {
                    couponDecreaseService.couponDecrease(coupon.getName(), coupon.getId());
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        Coupon persistCoupon = couponRepository.findById(coupon.getId())
                .orElseThrow(IllegalArgumentException::new);

        assertThat(persistCoupon.getAvailableStock()).isZero();
        System.out.println("잔여 쿠폰 개수 = " + persistCoupon.getAvailableStock());
    }

결과는 아래와 같다.

테스트가 성공적으로 수행된 것을 확인할 수 있다.

 

 

본 포스팅은 마켓 컬리의 기술 블로그를 참조하여 작성하였다.
https://helloworld.kurly.com/blog/distributed-redisson-lock/

'Dev' 카테고리의 다른 글

CPU-Scheduling  (2) 2024.04.07
RabbitMQ  (0) 2024.03.31
운영체제와 시스템 콜  (1) 2024.03.23
Thread Safe 와 동시성 제어  (0) 2024.03.17
MySQL의 인덱스  (1) 2024.03.16
Comments