본문 바로가기

자바 심화/TIL

Redisson 분산 락 구현

개요

예약 관리 시스템에서 동일한 시간대의 예약에 대한 동시성 제어를 하기 위해 다양한 lock 기법 중 Redisson 분산 락을 적용하기로 했다.

 

Redisson 분산 락 구현

1. Redisson 의존성 추가

// Redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'
  • build.gradle 파일에 Redisson 라이브러리 의존성을 추가한다.

 

2. Redisson 설정

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@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() {
        RedissonClient redisson;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        redisson = Redisson.create(config);
        return redisson;
    }
}
  • Config 파일을 통해 Redisson Client를 설정한다.
    • yml 파일에서 설정하는 것도 가능하다.

 

3. 분산 락 클래스 구현

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

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

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

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

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

 

4. 락 AOP 클래스 구현

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

    private final RedissonClient redisson;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.quit.reservation.infrastructure.lock.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 = redisson.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);
            }
        }
    }
}
  • @DistributedLock 어노테이션 선언 시 수행되는 aop 클래스
    1. 락의 이름으로 RLock 인스턴스를 가져온다.
    2. 정의된 waitTime까지 획득을 시도한다. 정의된 leaseTime이 지나면 잠금을 해제한다.
    3. DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행한다.
    4. 종료 시 무조건 락을 해제한다.

 

5. Custom SpringEL Parser 클래스

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

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);
    }
}
  • 전달받은 Lock 이름을 Spring Expression Language로 파싱하여 읽어온다.

 

6. Aop Transaction 클래스

import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}
  • AOP에서 트랜잭션 분리를 위한 클래스
    • 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하게끔 설정
    • 반드시 트랜잭션 커밋 이후 락이 해제되게끔 처리

 

7. 서비스 코드 적용

@DistributedLock(key = "#request.storeId + ':' + #request.reservationDate + ':' + #request.reservationTime")
public CreateReservationResponse createReservation(CreateReservationDto request, String customerId) {
    ... 서비스 코드 생략...
}

@DistributedLock(key = "#slotId")
    public void changeReservationStatusAsync(UUID reservationId, ReservationStatus status, UUID slotId) {
        ... 서비스 코드 생략...
    }
  • @DistributedLock 어노테이션을 통해 서비스 코드 호출 시 설정한 key로 락을 획득하도록 설정

 

테스트

테스트 컨트롤러 사용해서 호출 테스트

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/tests")
public class KafkaTestController {

    private final ReservationMessagingProducer reservationMessagingProducer;

    @PostMapping("/queue")
    public ResponseEntity<String> createSend(@RequestBody ReservationMessage message) {
        String key = message.getStoreId() + ":" + message.getUserId();
        reservationMessagingProducer.sendMessage("queue.process.success", key, message);
        return ResponseEntity.ok(key);
    }

    @PostMapping("/payment")
    public ResponseEntity<String> paymentSend(@RequestBody PaymentMessage message) {
        String key = String.valueOf(message.getReservationId());
        reservationMessagingProducer.sendMessage("payment.create.success", key, message);
        return ResponseEntity.ok(key);
    }
}

 

테스트 결과

예약 생성 테스트 결과

예약 진행 테스트 결과

 

카프카 토픽

 

애플리케이션 로그

  • 로그에서 서비스 로직 호출 시 락을 획득하고 로직 완료(Commit) 시 락을 해제하는 것을 확인할 수 있다.

 

정리

  • Redisson 분산 락을 사용해 서비스의 동시성 충돌 문제를 효과적으로 해결할 수 있었다.
  • Commit이 완료되고 락을 해제하도록 설정하는 것이 어려웠지만, 이번에 배운 것을 활용해 다음 구현 시에는 좀 더 나은 방식으로 할 수 있을 거 같다.

'자바 심화 > TIL' 카테고리의 다른 글

Kafka 비동기 처리 구현  (0) 2025.01.10
Kafka - 비동기 처리  (1) 2024.12.31
동시성 제어 시점과 데이터 일관성 유지 관점  (1) 2024.12.30
Redis - Redisson  (2) 2024.12.27
DB Lock  (1) 2024.12.26