Redisson 분산락 적용기

itbuddy·2024년 8월 20일

Spring & Java

목록 보기
3/8

카카오 페이 과제중에 동시성을 처리해야하는 상황에 직면 했다.
1. 사용자 포인트 충전
2. 음료 주문

동시성을 처리하려면 락이 필요한 이유?

바로 동시성 환경에서 데이터의 정합성을 보장하기 위해서이다.
DB에 락을 걸거나 redis를 이용해 분산락을 거는 것은 멀티 스레딩 환경에서 발생하는 RaceCondition을 해결하기 위한 해결책이다.

동시성 예제로 자주 등장하는 재고 차감을 예로 들어보겠습니다.
A라는 상품의 재고가 10개 존재합니다. 여러 명의 작업자들이 동시에 해당 재고를 사용한다고 가정해 보겠습니다.
이때, 락의 해제 시점이 트랜잭션 커밋 시점보다 빠르면 어떻게 동작할까요?

간단하게 말하자면 동시에 2건의 주문이 발생하여
A상품의 재고가 2개가 줄어 8개여야 하는데 1개만 줄어들어 상품 재고가 9개가 되는 경우가 발생한다 것이다.

Thread1Thread2stock
1read stock10
2order stock 110
3read stock10
4order stock 110
5save stock9
6save stock9

분산락 선택의 이유

락의 경우에는 낙관적락, 비관적락, 분산락이 존재하지만 해당 서비스가 고가용성의 환경에 놓일수 있다고 생각하여. 동시성을 처리하는 솔수션 중에 분산락이 가장 성능이 좋기 때문에 선택하였다.

코드 Redisson 적용

어노테이션으로 필요한 곳에 손쉽게 쓰며, SpEL(Spring Expression Langauge) 를 활용하여 Redisson의 키값을 의미 있는 고유한 값으로 생성하여 각각 필요한 순간에 락을 걸도록 적용하였다.

build.gradle

    dependencies { 
    ...
    //redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.redisson:redisson-spring-boot-starter:3.33.0'
    }

application.yaml

연결 설정을 YAML로 진행
설정 참고

spring:
 data:
  redis:
   host: localhost
   port: 6379

CustomSpringELParser.java

Redis로 락을 걸려면 각 작업마다 구별이 가능한 고유한 키값이 필요하다. 따라서 SpEL(Spring Expression Langauge) 이란 문법과 관련 라이브러리를 이용하여 고유한 키값을 생성하기 위한 클래스 이다.
SpEL(Spring Expression Langauge) 사용법 + 어노테이션에 SpEL로 값 전달하기

package org.kakaopay.coffee.config.distributionlock;

import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class CustomSpringELParser {

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        if(parameterNames == null) throw new RuntimeException("DistributionLoack 에러");
        SpelExpressionParser 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);
    }
}

DistributedLock.java

필요한 메서드에 편리하게 사용할 수있도록 어노테이션으로 작성한다.

package org.kakaopay.coffee.config.distributionlock;


import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributedLock {

    /**
     * 락의 이름
     */
    String value();

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

    /**
     * 락을 기다리는 시간 (default - 5s)
     * 락 획득을 위해 waitTime 만큼 대기한다
     */
    long waitTime() default 5L;

    /**
     * 락 임대 시간 (default - 3s)
     * 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
     */
    long leaseTime() default 3L;
}

DistributedLockAop.java

AOP 를 통하여 @DistributedLock 이 달려있는 메서드의 경우 분산락 로직이 작동하도록 한다.

package org.kakaopay.coffee.config.distributionlock;

import java.lang.reflect.Method;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.kakaopay.coffee.api.common.AopForTransaction;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
    private static final String REDISSON_LOCK_PREFIX = "LOCK";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(org.kakaopay.coffee.config.distributionlock.DistributedLock)")
    public Object redissonLock(final ProceedingJoinPoint joinPoint) throws Throwable{
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        String key = String.format("%s:%s:%s:%s",
            REDISSON_LOCK_PREFIX,
            method.getDeclaringClass().getName(),
            method.getName(),
            CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.value())
        );

        RLock rLock = redissonClient.getLock(key);

        try {
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());  // (2)
            if (!available) {
                log.info("REDISSON_LOCK|{}|NOT_AVAILABLE", key);
                return false;
            }
            log.info("REDISSON_LOCK|{}|LOCKED", key);
            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            try {
                rLock.unlock();
                log.info("REDISSON_LOCK|{}|UNLOCKED", key);
            } catch (IllegalMonitorStateException e) {
                log.info("REDISSON_LOCK|{}|ALREADY_UNLOCKED", key);
            }
        }
    }
}

이렇게 작성된 분산락 로직은 다음과 같이 사용하면 된다.


    @Transactional
    @DistributedLock("#menuCode")
    public Long addInventoryByMenuCode(Long menuCode, int inventory) {
        MenuEntity menuEntity = jpaQueryFactory.select(menu)
                                               .from(menu)
                                               .where(menu.menuCode.eq(menuCode))
                                                .orderBy(menu.id.desc())
                                               .fetchFirst();
        if(menuEntity == null) throw new IllegalArgumentException("존재하지 않는 메뉴입니다.");
        if (inventory < 0 && menuEntity.getInventory() < inventory)
            throw new IllegalArgumentException("재고가 부족합니다.");

        return jpaQueryFactory.update(menu)
                              .set(menu.inventory, menu.inventory.add(inventory))
                              .where(menu.menuCode.eq(menuCode)).execute();
    }

테스트 코드

        @Test
        @DisplayName("분산락 테스트 한가지 메뉴를 여러명의 유저가 주문")
        void testRedissonLock() throws Exception {
            // given
            List<UserEntity> users = createUserWithCount(CONCURRENT_COUNT);

            userJpaManager.saveAllAndFlush(users);

            Integer originQuantity = menuJpaReader.findById(MENU_ID).orElseThrow().getInventory();

            ExecutorService executorService = Executors.newFixedThreadPool(32);
            CountDownLatch latch = new CountDownLatch(CONCURRENT_COUNT);

            for (int i = 0; i < CONCURRENT_COUNT; i++) {
                int finalI = i;
                executorService.submit(() -> {
                    try {
                        orderService.order(
                            getOrderByIdx(users, List.of(makeOrderVo(1L, 1)),
                                finalI)
                        );
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    } finally {
                        latch.countDown();
                    }
                });
            }

            latch.await();


            // then
            MenuEntity resultMenu = menuJpaReader.findById(MENU_ID).orElseThrow();
            assertThat(resultMenu.getInventory()).isEqualTo(originQuantity - CONCURRENT_COUNT);
            List<UserEntity> resultUser = userJpaReader.findAllById(
                users.stream().map(UserEntity::getId).toList());
            assertThat(resultUser).extracting("point")
                                  .containsExactlyInAnyOrder(8500,8500,8500,8500,8500,
                                      8500,8500,8500,8500,8500);

        }

    }

Intellij 에서 수행중 문제가 있다면 다음글을 참고하길 바란다.
AOP MethodSignature getParameterNames is null

Ref

분산락이 적용된 프로젝트

마켓 컬리 분산락

profile
프론트도 조금 아는 짱구 같은 서버 프로그래머

0개의 댓글