[Spring] Redis(Redisson) 분산락을 활용한 좌석 선점 개발

오형상·2024년 10월 23일
0

Ficket

목록 보기
12/27

1. 좌석 선점

티켓팅 시스템에서는 좌석이라는 고유 자원을 여러 사용자가 동시에 접근할 때 중복 예약을 방지해야 합니다. 이를 해결하기 위해 다음과 같은 요구 사항을 고려해야 합니다:

  1. 긴 락 유지 필요: 좌석을 선점한 사용자가 결제를 완료할 때까지 해당 좌석을 다른 사용자가 선택하지 못하도록 해야 합니다.
  2. 동시성 처리: 여러 사용자가 동시에 좌석을 요청할 경우 데이터 일관성을 보장해야 합니다.

2. Redis와 Redisson을 사용하는 이유

Redis는 빠른 데이터 접근 속도분산락 기능을 제공하며, Redisson은 Redis의 Pub/Sub 기능을 활용해 락 점유 재시도를 효율적으로 처리합니다.

Lettuce의 한계

  • 스핀락 방식으로 락 점유를 재시도하므로 Redis 서버에 부하를 줄 수 있음.
  • TTL(만료 시간) 설정이 없으므로 장애 발생 시 락이 해제되지 않는 문제 발생.

Redisson의 장점

  1. TTL 설정 지원: 장애 발생 시에도 락이 자동으로 해제됩니다.
  2. Pub/Sub 기반 재시도: 락 점유 실패 시 이벤트를 통해 효율적으로 재시도합니다.

3. Redisson을 활용한 좌석 선점 로직

전체 플로우

  1. 사용자가 기존에 선점한 좌석이 있는지 확인합니다.
  2. 사용자가 선택한 좌석 수가 제한을 초과하지 않는지 검증합니다.
  3. 선택한 좌석 각각에 대해 락을 시도합니다.
  4. 다른 사용자가 이미 선점한 좌석이 있다면 전체 요청을 실패로 처리합니다.
  5. 모든 좌석을 성공적으로 선점한 경우, 선점 상태를 Redis에 저장합니다.

3.1 어노테이션 기반 분산락 관리

@DistributedLock 어노테이션

특정 메서드에 대해 분산락을 적용할 수 있도록 어노테이션을 정의합니다.

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

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

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


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

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

}

AOP 기반 락 관리

Redisson을 활용하여 락 점유 및 해제 로직을 AOP로 구현합니다.

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

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(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 lockKey = (String) CustomSpringELParser
                .getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());

        RLock rLock = redissonClient.getLock(lockKey);


        try {
            boolean acquired = rLock.tryLock(
                    distributedLock.waitTime(),
                    distributedLock.leaseTime(),
                    distributedLock.timeUnit()
            );

            if (!acquired) {
                throw new BusinessException(ErrorCode.FAILED_TRY_ROCK);
            }

            log.info("락 키 {} 획득 성공", lockKey);
            return aopForTransaction.proceed(joinPoint);

        } catch (BusinessException e) {
            if (rLock.isHeldByCurrentThread()) rLock.unlock();
            throw new BusinessException(ErrorCode.FAILED_DURING_TRANSACTION);
        }

    }
}

커스텀 파서 구현

스프링 EL 표현식을 사용하여 동적으로 락 키를 생성하는 커스텀 파서입니다.

@Slf4j
@NoArgsConstructor
public class 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);
    }

}

3.2 좌석 선점 서비스

좌석 선점 비즈니스 로직은 PreoccupyService에 정의됩니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class PreoccupyService {

    private final RedissonClient redissonClient;
    private final PreoccupyInternalService preoccupyInternalService;

    @Transactional
    public void lockSeat(SelectSeat request, Long userId) {

        executeWithRateLimiter(
                rateLimiterRegistry,
                "preoccupySeatLimiter",
                () -> {
                    preoccupySeat(request, userId);
                    return null; // Supplier<T>이므로 Void를 처리하기 위해 null 반환
                }
        );
    }


    public void preoccupySeat(SelectSeat request, Long userId) {
        UserSimpleDto user = CircuitBreakerUtils.executeWithCircuitBreaker(circuitBreakerRegistry,
                "getUserCircuitBreaker",
                () -> userServiceClient.getUser(userId)
        );

        log.info("요청한 유저의 ID: {}", user.getUserId());

        List<SelectSeatInfo> selectSeatInfoList = request.getSelectSeatInfoList();
        Set<Long> seatMappingIds = selectSeatInfoList.stream().map(SelectSeatInfo::getSeatMappingId).collect(Collectors.toSet());

        Long eventScheduleId = request.getEventScheduleId();

        // 해당 유저가 이미 예약한 좌석이 있는지 확인
        ensureUserHasNoSelectedSeats(eventScheduleId, user.getUserId());

        // 요청된 좌석 수와 예약 제한을 검증
        Integer reservationLimit = CircuitBreakerUtils.executeWithCircuitBreaker(
                circuitBreakerRegistry,
                "enterTicketingCircuitBreaker",
                () -> ticketingServiceClient.enterTicketing(String.valueOf(user.getUserId()), eventScheduleId)
        );

        validateSeatCount(seatMappingIds, reservationLimit);

        // 요청 좌석 중 이미 선점된 좌석이 있는지 확인
        validateSeatsAvailability(eventScheduleId, seatMappingIds);

        // 각 좌석을 사용자에 대해 잠금 처리
        selectSeatInfoList.forEach(selectSeatInfo -> lockSeat(eventScheduleId, user.getUserId(), selectSeatInfo.getSeatMappingId(), selectSeatInfo.getSeatGrade(), selectSeatInfo.getSeatPrice()));
    }
    
    private void lockSeat(Long eventScheduleId, Long userId, Long seatMappingId, String seatGrade, BigDecimal seatPrice) {
        String lockKey = RedisKeyHelper.getLockKey(eventScheduleId, seatMappingId); // 키 생성
        preoccupyInternalService.lockSeat(lockKey, userId, seatMappingId, eventScheduleId, seatGrade, seatPrice);
        log.info("좌석 {}가 사용자 {}에 의해 선점되었습니다. (등급: {}, 가격: {})", seatMappingId, userId, seatGrade, seatPrice);
    }
}

3.3 내부 로직 분리

AOP 내부 호출 문제를 방지하기 위해 로직을 PreoccupyInternalService로 분리합니다.

@Service
@RequiredArgsConstructor
public class PreoccupyInternalService {

    private final RedissonClient redissonClient;

    /**
     * 좌석에 대해 분산 락을 획득하고 선점 처리
     *
     * @param lockName        락 키 (Redis에서 사용할 고유 락 이름)
     * @param userId          좌석을 선점하는 사용자 ID
     * @param seatMappingId   좌석 매핑 ID
     * @param eventScheduleId 이벤트 일정 ID
     */
    @DistributedLock(key = "#lockName")
    public void lockSeat(String lockName, Long userId, Long seatMappingId, Long eventScheduleId, String seatGrade, BigDecimal seatPrice) {
        log.info("lockName: {}", lockName);
        holdSeat(eventScheduleId, userId, seatMappingId, seatGrade, seatPrice);
    }

    /**
     * Redis에 사용자 및 좌석 정보를 저장하여 좌석 선점 처리
     *
     * @param eventScheduleId 이벤트 일정 ID
     * @param userId          좌석을 선점하는 사용자 ID
     * @param seatMappingId   좌석 매핑 ID
     */
    public void holdSeat(Long eventScheduleId, Long userId, Long seatMappingId, String seatGrade, BigDecimal seatPrice) {
        String seatKey = RedisKeyHelper.getSeatKey(eventScheduleId);
        String userKey = RedisKeyHelper.getUserKey(userId);

        try {
            // 좌석 상태 저장
            RMap<String, String> seatStates = redissonClient.getMap(seatKey);
            String seatField = "seat_" + seatMappingId;
            String seatInfo = createSeatInfoJson(userId, seatGrade, seatPrice);
            seatStates.put(seatField, seatInfo);

            // 사용자 예약 정보 병합
            RMap<String, String> userEvents = redissonClient.getMap(userKey);
            String eventField = "event_" + eventScheduleId;
            userEvents.merge(eventField, "[" + seatMappingId + "]", (oldValue, newValue) -> mergeSeatIds(oldValue, seatMappingId));

            // TTL 설정
            setTTL(seatKey, userKey);

        } catch (Exception e) {
            log.error("Redis 작업 중 오류 발생 (eventScheduleId: {}, userId: {}, seatMappingId: {})", eventScheduleId, userId, seatMappingId, e);
            throw new RuntimeException("좌석 선점 처리 중 오류가 발생했습니다.", e);
        }
    }
}

4. 테스트

1000명의 사용자가 동시에 하나의 좌석을 요청하는 상황을 테스트합니다.

@Test
@DisplayName("1000명의 사용자가 동일한 좌석 예약 - 동시성 테스트")
void testMultiReserve() throws InterruptedException {
    int threadNum = 100;
    CountDownLatch latch = new CountDownLatch(threadNum);
    AtomicInteger successCounter = new AtomicInteger(0);

    ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
    for (long i = 1L; i <= threadNum; i++) {
        Long userId = i;
        executorService.submit(() -> {
            try {
                preoccupyService.preoccupySeat(request, userId);
                successCounter.incrementAndGet();
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();
    executorService.shutdown();

    assertEquals(1, successCounter.get(), "한 명만 예약에 성공해야 합니다.");
}

5. 결과

테스트 결과 1

테스트 결과 2


Reference

0개의 댓글