Redis를 이용한 분산락

함승완·2024년 10월 29일

나에게 하는 질문

영화 예매 프로젝트를 진행하는 과정에서 만약에 1000명이 동시에 같은 좌석을 예약을 한다면 어떻게 할까?

나는 여기서 synchronized를 사용하면 되지않을까? 라고 생각을 했다.

public synchronized void reserveSeat(Long screenId, int seatNumber, Long screenTimeId, String username) {

    final User user = userService.findUser(username);
    final Optional<Reservation> existReservation = reservationRepository.findReservation(screenTimeId, screenId);

    // 이미 예약이 된 좌석인지 확인
    if (existReservation.isPresent()) {
        if (existReservation.get().getReservationStatus().equals(ReservationStatus.CONFIRMED)) {
            throw new BadRequestException("이미 예약이 된 좌석입니다.");
        } else if (existReservation.get().getReservationStatus().equals(ReservationStatus.CANCELLED)) {
            existReservation.get().updateReservation(ReservationStatus.CONFIRMED, user);
            simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, existReservation);
            return;
        }
    }

    // 예약 정보가 없는 경우 새로운 예약 생성
    final ScreenTime screenTime = screenTimeService.findScreenTime(screenTimeId);
    final Seat seat = seatService.findSeat(screenId, seatNumber);
    final Reservation reservation = Reservation.builder()
            .seat(seat)
            .user(user)
            .screenTime(screenTime)
            .reservationStatus(ReservationStatus.CONFIRMED)
            .build();
    reservationRepository.save(reservation);

    simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, reservation);
}

원하는대로 단 하나의 유저만 좌석예약은 성공한다.
하지만 synchronized를 사용하면 단일 서버가 아닌 각기 다른 서버를 이용하는 프로젝트이거나 단일 서버 프로젝트를 더 고도화 시킬 때 문제를 야기할 수 있다고 느꼈다.

다른 방식을 선택해야한다.

다른 방식

낙관적 락과 비관적 락

적용 했을 시 버전 관리를 통해 데이터베이스에서 자원에 대한 동시성 제어를 할 수 있다. 하지만 하나의 트랜잭션이 끝날 때까지 다른 트랜잭션 접근을 차단하는 방식이다.

Redis 사용

Redis 라이브러리인 Lettuce 사용

기본적인 Lettuce는 스핀 락(spin Lock)을 제공한다. 하지만 스핀 락은 무한루프를 돌며 계속 키를 요청하기 때문에 많은 비용이 발생한다.

Redisson 사용

기본적인 Redisson은 분산 락을 제공한다. 분산 락은 무한 루프를 돌지 않고
Pub/Sub기능을 통해 클라이언트에게 준비되었단 신호를 주기에 적은 비용이 생긴다.

Redisson을 이용한 구현

    public void reserveSeat(Long screenId, int seatNumber, Long screenTimeId, String username) {

        // 분산 락을 위한 고유한 키 설정 (screenTimeId와 seatNumber 기준으로 설정)
        String lockKey = "reserveSeat:" + screenTimeId + ":" + seatNumber;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 락을 얻기 위해 대기 시간을 5초, 락이 활성화되는 시간을 10초로 설정
            if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
                final User user = userService.findUser(username);
                final Optional<Reservation> existReservation = reservationRepository.findReservation(screenTimeId, screenId);

                // 이미 예약된 좌석인지 확인
                if (existReservation.isPresent()) {
                    if (existReservation.get().getReservationStatus().equals(ReservationStatus.CONFIRMED)) {
                        throw new BadRequestException("이미 예약이 된 좌석입니다.");
                    } else if (existReservation.get().getReservationStatus().equals(ReservationStatus.CANCELLED)) {
                        existReservation.get().updateReservation(ReservationStatus.CONFIRMED, user);
                        simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, existReservation);
                        return;
                    }
                }

                // 새 예약 생성
                final ScreenTime screenTime = screenTimeService.findScreenTime(screenTimeId);
                final Seat seat = seatService.findSeat(screenId, seatNumber);
                final Reservation reservation = Reservation.builder()
                        .seat(seat)
                        .user(user)
                        .screenTime(screenTime)
                        .reservationStatus(ReservationStatus.CONFIRMED)
                        .build();
                reservationRepository.save(reservation);

                simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, reservation);
            } else {
                throw new RuntimeException("좌석 예약 중에 문제가 발생했습니다. 다시 시도해 주세요.");
            }
        } catch (InterruptedException e) {
            throw new RuntimeException("좌석 예약 중에 문제가 발생했습니다.", e);
        } finally {
            // 락 해제
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

구현 후 문제점

try-catch문과 if문이 너무 적나라하게 많이 들어있고 하나의 메서드가 너무 부담이 크다고 느꼈다.
따라서 Lock에 대한 로직과 비즈니스 로직을 분리하기로 했다.

AOP 적용 후 구현

DistributedLock.java

// DistributedLock.java

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

DistributedLock.java는 분산락을 이용할 메서드에 어노테이션을 달아서 사용할 수 있게 만들었다. 파라미터 값은 key는 필수, 나머지는 커스텀 할 수 있게 만들었다.

DistributedLockAop.java

// DistributedLockAop.java

@Aspect
@Component
@Slf4j
public class DistributedLockAop {

    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    public DistributedLockAop(RedissonClient redissonClient, AopForTransaction aopForTransaction) {
        this.redissonClient = redissonClient;
        this.aopForTransaction = aopForTransaction;
    }

    @Around("@annotation(com.movie.reservation.global.lock.DistributedLock.class)")
    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);  // (1)

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

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

DistributedLockAop.java는 DistributedLock 어노테이션 사용 시 수행되는 aop클래스이다.
과정은
1. 락의 이름으로 RLock 인스턴스를 가져온다.
2. 정의된 waitTime까지 획득을 시도한다 하지만 정의된 leaseTime이 지나면 락을 해제한다.
3. DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행된다.
4. 종료시 무조건 락을 해제한다. (finally)

여기까지는 Redisson으로 분산락을 제공할 때 나오는 기본 메서드이다.
중점적으로 보아야 할 클래스들이 이제 나온다.

중점적으로 보아야 하는 클래스

CustomSpringELParser.java

// CustomSpringELParser.java

public class CustomSpringELParser {
    private CustomSpringELParser() {
    }

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        if (parameterNames == null || parameterNames.length == 0) {
            throw new BadRequestException("Method parameter names cannot be null or empty.");
        }

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

CustomSpringELParser 는 전달받은 Lock의 이름을 Spring Expression Language 로 파싱하여 읽어온다.
Spring Expression Language를 사용하면 Lock의 이름을 자유롭게 가져올 수 있다.
예제코드

@DistributedLock(key = "#회원가입락")
public void 회원가입 (String 회원가입락){
----비즈니스로직---
}
@DistributedLock(key = "#좌석예약락이름")
public void 좌석예약 (String 좌석예약락이름){
----비즈니스로직---
}

이런식으로 활용 가능하다.

AopForTransaction.java

// AopForTransaction.java

@Component
public class AopForTransaction {

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

스프링 프레임 워크에서 제공하는 트랜잭션 어노테이션에서 전파전략을 REQUIRES_NEW를 옵션을 제공하여 부모 트랜잭션 유무에 관계없이 새로 트랜잭션을 시작하고 트랜잭션의 커밋이나 롤백시 무조건 락이 해제되게 만들었다.

트랜잭션 커밋 후 락이 해제 되어야 하는 이유


위 그림대로 안전하게 유지되어야 한다.
하지만 트랜잭션 커밋 or 롤백이 이루어지지 않을 때 밑에 그림처럼 이루어 질 수 있다.


위 그림처럼 트랜잭션 커밋도 전에 락이 해제되면 다음에 락을 받은 유저도 같이 같은 좌석을 예약할 수 있다.
좌석은 하나인데 2명이 앉으면 많은 문제를 야기할것이다.

최종 동시성 제어 로직

	@Transactional
    @DistributedLock(key = "#screenTimeId + ':' + #seatNumber")
    public void reserveSeat(Long screenId, int seatNumber, Long screenTimeId, String username) {

        final User user = userService.findUser(username);
        final Optional<Reservation> existReservation = reservationRepository.findReservation(screenTimeId, screenId);

        if (existReservation.isPresent()) {
            if (existReservation.get().getReservationStatus().equals(ReservationStatus.CONFIRMED)) {
                throw new BadRequestException("이미 예약이 된 좌석 입니다.");
            } else if (existReservation.get().getReservationStatus().equals(ReservationStatus.CANCELLED)) {
                existReservation.get().updateReservation(ReservationStatus.CONFIRMED, user);
                simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, existReservation);
                log.info("예약 성공한 유저 : {}", username);
                return;
            }
        }

        final ScreenTime screenTime = screenTimeService.findScreenTime(screenTimeId);
        final Seat seat = seatService.findSeat(screenId, seatNumber);
        final Reservation reservation = Reservation.builder()
                .seat(seat)
                .user(user)
                .screenTime(screenTime)
                .reservationStatus(ReservationStatus.CONFIRMED)
                .build();
        reservationRepository.save(reservation);

        simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, reservation);
        log.info("예약 성공한 유저 : {}", username);
    }

분산 락에 필요한 로직을 분리함으로써 기존의 비즈니스 로직을 그대로 쓸 수 있게 되었다.

최종적으로 느낀 점

  1. 만약에 천명이 같이 좌석 예약 실행시 스프링 프레임워크에서 지원하는 트랜잭션 어노테이션으론 한계가 있을 수 있다. loop문으로 테스트 시 같은 좌석 예약에 성공하는 인원이 4명이 생겼다. (느끼기엔 같은 트랜잭션에 4명이 동시에 들어갔을 것이다.)
  2. 낙관 락은 충돌에 대한 처리를 고민을 많이 해야될 것 같다.
  3. 비관 락은 락을 대기하는 트랜잭션이 많아지면 리소스를 많이 소모한다.
  4. 분산 락은 나중에 고도화 된 서비스가 되어도 계속 들고 갈 수 있고 의도대로 처리해준다.
  5. AOP를 이용한 관심사 분리를 통하여 유지보수성이 중요하다는 걸 느꼈다. 서비스가 나중에 고도화 되어서 동시성 제어를 하는 좋아요 기능을 만든다 할때 이것을 접목시키면 될 것 같다.
profile
좋은 개발자 좋은 코딩 좋은 컴퓨터

0개의 댓글