본문 바로가기
개발/Spring

[Spring] AOP 기반 Redis 분산락 적용

by baau 2024. 5. 11.

분산 시스템은 인터넷을 통해 연결된 컴퓨터가 하나의 시스템처럼 동작하는 환경을 말한다.

따라서, 높은 처리량과 확장성, 고가용성을 보장해 준다. 하지만 분산 시스템은 데이터의 일관성 유지와 트랜잭션 관리 등 여러 복잡한 문제를 해결해야 하는 경우가 존재한다.

 

특히, 여러 노드가 동시에 공유 데이터를 변경하려고 할 때, 동시성 문제가 발생하여 데이터의 일관성이 무너지게 된다. 이를 방지하기 위해서는 분산락을 사용할 수 있고, 분산 락은 여러 노드가 동시에 접근 가능한 자원에 대한 접근을 하나의 노드만 접근할 수 있도록 제한한다.

 

이번 포스팅에서는

[Inflearn] 재고시스템으로 알아보는 동시성이슈 해결방법 에서 배운 Redis를 활용하여 분산락을 구현하는 2가지 방법

유익한 블로그 글이었던 Kurly Tech Blog - 풀필먼트 입고 서비스팀에서 분산락 사용 방법 에서 배운 어노테이션 기반 분산락 구현을 실습 및 기록하고자 한다.

 

Redis로 분산락 구현

분산락은 Redis의 클라이언트인 LettuceRedisson 을 통해 구현 가능하다.

 

Lettuce

Lettuce는 Netty 기반의 Redis 클라이언트이며, 별도의 라이브러리 없이 spring-data-redis 의존성을 추가했다면 사용할 수 있다.

Redis의 setnx 명령어를 통해 분산락을 구현할 수 있다.

*setnx : key-value로 데이터를 관리하는 레디스에서 key 값이 존재하지 않으면 데이터를 set 하는 atomic 한 명령어이다.

 

별도의 설정 없이 간단히 구현할 수 있는 반면에 스핀락 방식으로 Redis에게 락이 해제되었는지에 대한 요청을 지속적으로 보내 요청이 많을 경우 Redis의 부하가 생기게 된다.

 

@Component
@RequiredArgsConstructor
public class RedisLockRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public Boolean lock(Long key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private static String generateKey(Long key) {
        return key.toString();
    }
}

 

lock 메서드 내의 setIfAbsent(key, value, timeout) 메서드는 레디스의 setnx를 사용할 수 있는 메서드이며, 키가 존재하지 않아 데이터를 set 하면 true를 리턴하고, 이미 락이 걸린 상태 (키가 세팅되어 있는 상태) 라면 false를 리턴한다.

 

unlock 은 key 값을 삭제함으로 쉽게 구현가능하다.

 

@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

    public void decrease(Long productId, Long quantity) throws InterruptedException {

        while (!redisLockRepository.lock(productId)) {
            Thread.sleep(100);
        }

        try {
            stockService.decrease(productId, quantity);
        } finally {
            redisLockRepository.unlock(productId);
        }
    }

}

 

lettuce를 사용할 때는 스핀락(while) 을 통해 구현이 가능하다. 이미 락 걸려 있을 경우, 잠시 sleep 했다가 다시 락을 획득할 수 있는지 확인한다. 따라서 lettuce의 경우 요청이 많을 경우 Redis에 큰 부하를 줄 수 있다.

 

 

Redisson

별도의 Redisson 라이브러리를 사용해야 하지만, Lock 인터페이스를 지원한다. 따라서 Lock에 대한 retry와 timeout 같은 설정을 쉽고 안전하게 사용할 수 있다.

Redisson의 경우 스핀락을 사용하지 않고, Pub/Sub 방식을 이용한다. 락이 해제되면 락을 subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락을 획득 시도한다. 

 

Spring Boot를 사용하는 경우 아래 링크에서 의존성을 가져와 build.gradle에 추가해야 한다.

https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter

dependencies {
	implementation 'org.redisson:redisson-spring-boot-starter:3.23.5'
}

 

@Component
@Slf4j
@RequiredArgsConstructor
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final StockService stockService;

    public void decrease(Long productId, Long quantity) {

        RLock lock = redissonClient.getLock(productId.toString());

        try {
            boolean available = lock.tryLock(15, 1, TimeUnit.SECONDS);

            if (!available) {
                log.info("lock 획득 실패");
                return;
            }
            
            stockService.decrease(productId, quantity);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}

 

redissonClient.getLock() 을 통해 락을 획득하고, tryLock() 을 통해 락 획득을 시도한다.

락 획득을 실패했을 경우, 스핀락과 같이 레디스에 재확인하는 것이 아니라 대기 상태로 들어가 메시지가 오기를 기다린다.

 

tryLock(long waitTime, long leaseTime, TimeUnit unit)

  • waitTime : 락 획득을 대기하는 시간
  • leaseTime : 락을 점유할 수 있는 최대 시간
  • unit : 각 시간의 단위

waitTime 시간 동안 Lock 획득을 시도하며 Lock을 획득 후 leaseTime 시간 이후 자동으로 UnLock 된다. 따라서 락 획득에 대한 재시도 로직과 락 획득에 대한 오류, 타임아웃 실패로 인한 무한 대기 등에 대해서 걱정하지 않아도 된다.

 

 

분산락에 대해서는 내용은 끝났지만, 위의 코드들은 몇가지 문제점이 있다.

  • 비즈니스 로직과 관련 없는 분산락을 획득/해제하는 로직이 위아래로 사용되고 있기 때문에 코드가 이해하기 어렵고 복잡하다.
  • 추가로 다른 로직에도 분산락이 필요할 경우, 분산락을 획득/해제하는 로직을 또 추가해야 한다.

위 문제를 해결하기 위해서 Spring AOP를 사용할 수 있다.

저번에 AOP와 어노테이션 기반 AOP에 대해서 공부(Annotation 기반 AOP 관련 포스팅) 하였기에

Kurly Tech Blog - 풀필먼트 입고 서비스팀에서 분산락 사용 방법 에 대해서 쉽게 이용하고 적용할 수 있었다.

 

1. 어노테이션 생성

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

    String key();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 5L;

    long leaseTime() default 3L;
}

 

DistributedLock 어노테이션의 파라미터로 key는 필수이고, 나머지는 각 데이터의 특성에 맞게 커스텀이 가능하다.

 

2. Aspect 추가

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

    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(labs.minnseong.stocksystemconcurrencyissue.annotation.DistributedLock)")
    public Object lock(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 serviceName : {}, key : {}", method.getName(), key);
            }
        }
    }
}

 

DistributedLock 어노테이션이 붙은 메서드를 호출할 때, 분산락을 획득/해제하는 부가적인 작업을 할 수 있도록 하는 Aspect이다.

분산락을 획득하고 해제하는 코드 베이스는 블로그 상단에 Redisson을 활용한 분산락 구현과 동일하다.

 

추가로, 흥미로웠던 부분이 있었다.

@Service
@Transactional
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    @DistributedLock(key = "#productId")
    public void decrease(Long productId, Long quantity) {
        Stock stock = stockRepository.findByProductId(productId).orElseThrow();
        stock.decrease(quantity);
    }
}

 

기존 코드에서 분산락의 Key가 되는 값은 decrease 메서드의 productId이다. 

productId를 통해 하나의 product 레코드에 대해서 락을 걸어 데이터의 일관성을 보장한다.

 

만약에 CustomSpringELParser를 사용하지 않았더라면 매개변수인 productId를 동적으로 가져올 수 없다.

모든 product에 대해서 Key는 LOCK:#productId 가 된다. 그러면 하나의 product에 락을 걸었을 경우 다른 product도 락이 걸리는 문제가 된다.

 

CustomSpringELParser를 사용한다면, 분산락의 Key를 LOCK:1로 정상적으로 가져올 수 있다.

또한, 아래와 같은 Spring Expression Language를 파싱 해서 읽어오기도 가능하여 자유롭게 키 설정이 가능하다.

@DistributedLock(key = "#model.getName().concat('-').concat(#model.getShipmentOrderNumber())")

 

CustomSpringELParser 코드는 아래와 같다. 사실 아래 코드를 완전히 이해는 못했지만.. 대략적으로 어떤 역할을 하는 코드인지 알 수 있었다.

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);
    }
}

 

또 다른 부분은 아래 코드를 통해 별도의 트랜잭션으로 동작하게 한 것이다. 반드시 트랜잭션 커밋 이후 락이 해제되어야 한다.

락의 해제가 트랜잭션 커밋 보다 먼저 된다면 데이터의 정합성이 깨질 수 있다.

@Component
public class AopForTransaction {

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

 

@DistributedLock 어노테이션이 붙은 메서드에 대해서 테스트 코드로 검증해 본 결과 분산락이 잘 되었음을 확인할 수 있었다.

@Service
@Transactional
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    @DistributedLock(key = "#productId")
    public void decrease(Long productId, Long quantity) {
        Stock stock = stockRepository.findByProductId(productId).orElseThrow();
        stock.decrease(quantity);
    }
}

 

 

AOP를 통해서 비즈니스 로직과 분산락 처리 코드를 분리할 수 있었고, 이제는 Annotation 만 붙인다면 쉽게 분산락을 구현할 수 있는 구조가 되었다.

 

참고