[Redis] Redis를 이용한 분산락 구현

이재민·2024년 5월 6일

Redis

목록 보기
6/6

분산락 구현

사내 분산락을 적용하기 앞서 Java Redis Client의 분산락 구현 방식에 대한 장/단점을 학습하였고 이를 포스팅하였다.

어노테이션 구현

AOP를 이용하여 분산락을 적용하고자 하는 메소드에 어노테이션만 붙인다면, 메서드 실행 전 후에 분산 락이 처리되도록 구현하였다.

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

    /**
     * 락의 이름.
     * Spring EL 표현식을 사용하여 동적으로 키를 생성할 수 있습니다.
     */
    String key();

    /**
     * 락 enum
     */
    LockEnum lockEnum();

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

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

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

Aop 구현

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

    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(~~~DistributedLock)")
    public Object getLock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

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

        try {
			// (3)
            boolean available = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!available) {
            	// (4)
                generateLockFailMessage(distributedLock.lockEnum());
                return false;
            }
			
            // (5)
            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException ex) {
            log.warn("error occurred while tryLock. serviceName: {}, key: {}, stackTrace: {}", method.getName(), key, ExceptionUtils.getStackTrace(ex));
            throw new SpecNotAllowException(ex);
        } finally {
            try {
            	// (6)
                if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            } catch (IllegalMonitorStateException ex) {
                log.warn("Redisson Lock Already UnLock. serviceName: {}, key: {}, stackTrace: {}", method.getName(), key, ExceptionUtils.getStackTrace(ex));
            }
        }
    }

    private void generateLockFailMessage(LockEnum lockEnum) {
        LockEnum findLockEnum = LockEnum.findLockEnum(lockEnum);

        if (Objects.nonNull(findLockEnum)) {
            throw new LockAcquireFailException(findLockEnum.getAlertMessage());
        }
    }

DistributedLockAop 클래스는 @DistributedLock 어노테이션 적용된 메소드에 AOP 기능을 제공한다. AOP 로직이 동작하면서 Redisson의 분산락을 이용하게 된다.

코드내 주석의 번호는 아래 번호와 같습니다.
@DistributedLock 어노테이션이 붙은 메소드가 호출되면 DistributedLockAop 클래스의 getLock 메소드가 실행된다.
1. Spring Expression Language (SpEL)을 이용해 동적 키를 생성한다.
2. 해당 키로 RLock 객체를 반환받는다.
3. waitTime 동안 락 획득을 시도한다.
4. waitTime 동안 락을 획득하지 못하면 커스텀 메시지를 제공합니다. ex) 선착순 이벤트 마감 등
5. 락을 획득했다면 비즈니스 로직을 수행한다.
6. 현재 스레드가 잠금을 보유하고 있는지 확인 후 잠금을 해제합니다. 다른 스레드가 락을 해제하지 못하도록 한다.

RedissonClient는 다양한 종류의 분산 잠금을 제공한다.

크게 2가지의 Lock 인터페이스에 대해 설명하고자 한다.

  1. Lock
    모든 Redisson 인스턴스에서 락을 획득하기 위해 대기하는 다른 스레드에게 알림을 보내기 위해 pub/sub 채널을 사용한다.

  2. FairLock
    공정 락은 스레드가 요청한 순서대로 락을 획득하게 됨을 보장한다.
    모든 대기중인 스레드는 큐에 들어가며 특정 스레드가 종료된다면 Redisson은 그 스레드의 반환시간을 기다리게 된다.
    때문에 특정 여러 스레드가 어떤 이유로 종료되게 된다면 지연 시간은 더욱 늘어나게 되고 성능적으로 느린 결과를 제공할 수 있다.

동적 문자열 파싱

public class CustomSpringELParser {
	// (1)
    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++) {
        	// (2)
            context.setVariable(parameterNames[i], args[i]);
        }
		
        // (3)
        return parser.parseExpression(key).getValue(context, Object.class);
    }
}

요청 별 락 키를 생성하기 위해서 CustomSpringELParser 클래스를 이용하였다.
CustomSpringELParser는 Spring EL(SPEL)으로 작성될 수 있어 동적으로 락 키를 생성할 수 있다.

  1. 메서드의 파라미터 이름, 호출된 메서드의 실제 인수 값들을 가져온다.
  2. 인수를 해당 매개 변수 이름에 할당한다.
  3. SPEL 문자열 파싱.

마치며

아래 좋은 기술 블로그들을 참고하여 분산 락 구현을 완료하였습니다.

아래 참고 링크에 있는 여기어때 이벤트 처리 구성도

현재 분산락을 적용한 아키텍처는 아래 여기어때 이벤트 처리 구성도와는 다르게 직접 DB에서 데이터를 읽어오는 구조를 가져갔습니다. (이는 다른 이벤트에서는 Redis에서 현황을 조회하도록 변경하여 빠른 응답을 제공하였습니다.)

또한 현재 사내 서비스는 Kafka와 같은 메시징 서비스를 적용하고 있지 않기에 SQS 혹은 ApplicationEventPublisher 로 대체할 수 있었지만 쿠폰이 정상적으로 발급됐는지 응답 값이 필요했기에 비동기적 아키텍처를 구성하지 않았습니다. (응답 값이 필요없던 이벤트에서는 SQS 메시징 시스템을 이용하여 처리하였습니다.)

위와 같은 아키텍처를 가져갔기 때문에 락 획득에 있어 적절한 waitTime을 설정해주기 위해 테스트를 반드시 수행해야 합니다.
만약 적절한 waitTime을 설정하지 못하게 된다면 Validation 체크를 하는 동안 락 획득을 대기하는 스레드들이 나가떨어질 수 있기 때문입니다.

이벤트 개발 담당자로 배정됨으로써 분산락에 대해 한층 더 깊은 고민과 학습을 진행하게 되었고 선착순 쿠폰 발급을 제외한 선착순으로 재화를 발급해주는 이벤트 등 다양한 영역에서 사용중에 있게 되었습니다.

참고
https://helloworld.kurly.com/blog/distributed-redisson-lock/
https://techblog.gccompany.co.kr/redis-kafka%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%BF%A0%ED%8F%B0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B0%9C%EB%B0%9C%EA%B8%B0-feat-%EB%84%A4%EA%B3%A0%EC%99%95-ec6682e39731

profile
문제 해결과 개선 과제를 수행하며 성장을 추구하는 것을 좋아합니다.

0개의 댓글