Post

Redis를 이용한 대기열 구현

Redis를 이용한 대기열 구현

목적


기존의 데이터베이스로 관리하던 대기열 시스템을 Redis를 이용하여 데이터베이스의 부하를 줄인다.


대기열 개발방식 고찰


은행창구 방식

  • 만료된 대기열의 큐만큼 서비스를 이용할 수 있는 토큰을 활성화
  • 현재 대기열은 데이터베이스를 이용하여 은행 창구 방식으로 관리된다.
  • 대기열 시스템을 요약하자면 아래와 같다.
    1. 콘서트 스케줄 날짜 조회를 시도하는 사용자에게 토큰을 발급하고 데이터베이스에 저장한다. 토큰의 상태값은 WAIT이다.
    2. 만료된 토큰을 기준으로 스케줄러를 이용하여 일정시간마다 WAIT의 토큰을 ACTIVE로 변경한다. (만료시간은 5분 - 평균 유저활동 시간)
    3. 5분 내에 콘서트 예약이 이루어지지 않는다면 스케줄러를 이용하여 ACTIVE에서 EXPIRE로 변경한다.
    4. 예약이 완료된다면 ACTIVE에서 EXPIRE로 변경한다.
  • 장점
    • 서비스를 이용하는 유저를 일정한 수 만큼 유지할 수 있다.
  • 단점
    • 유저의 행동에 따라 대기열의 전환시간이 불규칙하다.

놀이공원 방식

  • N초마다 M개의 Active token으로 전환한다.
  • Redis를 이용하여 개선될 방식이다.
  • 장점
    • 대기열의 사용자들이 일정한 시간으로 서비스의 진입이 가능하다.
  • 단점
    • 서비스를 이용하는 사용자의 수가 보장되지 않는다.


대기열 기능개발


  • RDB에서 관리하던 기존 대기열을 아래의 Redis 자료구조를 이용하여 처리함
    • 기존
      • 데이터베이스를 이용한 대기열 관리
    • 신규
      • 2가지 Redis 자료구조를 이용하여 대기열을 구성함
      • 대기큐(WaitingQueue) : Sorted Set 자료구조를 이용
      • 활성큐(ActiveQueue) : Set 자료구조 이용
  • 큐의 만료시간 설정
    • 기존 : 스케줄러를 이용하여 5분경과된 데이터의 상태값 변경
    • 신규 : 활성큐의 Key에 TTL을 적용하여 만료시간을 조절
  • 만료시간 연장
    • 기존 : 토큰을 이용하여 해당 큐를 조회하여 만료시간을 연장함
    • 신규 : 토큰을 이용하여 해당 큐의 TTL을 갱신

코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@Service
@RequiredArgsConstructor
public class QueueRedisService {

    private final UserAccountRepository userAccountRepository;

    private final WaitingQueueRedisRepository waitingQueueRedisRepository;
    private final ActiveQueueRedisRepository activeQueueRedisRepository;

    /**
        대기큐 생성
    */
    public String createWaitQueue(Long userId) {
        String lockKey = "lock:user:" + userId;
        // 10초동안 같은 유저가 큐에 진입하지 못하도록 함
        Boolean isUserQueued = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "LOCK:%d".formatted(userId), 10, TimeUnit.SECONDS);

        if (Boolean.FALSE.equals(isUserQueued)) {
            String s = redisTemplate.opsForValue().get(lockKey);
            log.error("user already in the wait Queue : userId - {}, lockValue - {}", userId, s);
            return "";
        }

        try {
            UserAccount userAccount = userAccountRepository.findById(userId);
            String token = generateToken();
            // 생성된 토큰을 사용자 정보에 저장
            userAccount.saveToken(token);
            userAccountRepository.save(userAccount);
            // 대기열에 사용자의 정보와 토큰을 저장
            waitingQueueRedisRepository.save(userAccount.getId(), token);
            return token;
        } finally {
            redisTemplate.delete(lockKey);
        }
    }

    /**
        활성큐('Active Queue')를 생성한다.
            - 놀이공원 방식의 `대기열`을 구성
            - 파라미터(limit)만큼의 큐를 `대기큐`에서 `활성큐`로 변경
            - 스케줄러를 사용하여 변경함
    */
    public void createActiveQueue(int limit) {
        Set<WaitingQueueRedisDto> waitingQueues = waitingQueueRedisRepository.getQueueByRange(limit);
        waitingQueues.forEach(dto -> {
            activeQueueRedisRepository.save(dto.getToken(), dto.getUserId());
            waitingQueueRedisRepository.remove(dto.getToken(), dto.getUserId());
        });
    }

    /**
        활성큐의 만료시간을 연장한다.
     */
    public void extendExpiration(String token) {
        activeQueueRedisRepository.extendExpiration(token);
    }

    /**
        대기큐의 순위를 리턴한다. - 사용자의 접속 순서를 확인할 수 있음
     */
    public Long getRank(String token, Long userId) {
        return waitingQueueRedisRepository.getRank(token, userId);
    }

    /**
        활성큐를 검증한다.
     */
    public boolean verify(String token) {
        return activeQueueRedisRepository.verify(token);
    }

    /**
        파라미터 범위만큼 대기큐를 조회한다.
     */
    public Set<WaitingQueueRedisDto> getWaitingQueues(int limit) {
        return waitingQueueRedisRepository.getQueueByRange(limit);
    }

    private String generateToken() {
        String uuid = UUID.randomUUID().toString();
        return uuid.substring(uuid.lastIndexOf("-") + 1);
    }
}

부하테스트

목적

부하테스트를 실시하여 대기열에서 처리열로 이동할 수 있는 토큰의 수량 및 시간을 특정할 수 있다.

테스트 환경

  • 서버: 로컬 테스트
    • 사양: Mac CPU(M3) , RAM(18G)
  • 데이터베이스 : MySql (Server version: 8.3.0 MySQL Community Server - GPL)
  • Redis: redis_version: 6.2.14
  • 부하테스트 : k6

테스트 시나리오 및 결과

  1. 30초 동안 점진적으로 사용자를 1명에서 1000명의 늘린다.
  2. 30초 동안 1000명의 사용자를 유지한다.
  3. 30초 동안 점진적으로 사용자를 1000명에서 1명으로 줄인다.

Desktop View

테스트 결과

  • 평균: 7.99ms
  • 최대: 86.06ms
  • 평균 TPS: 660/s

1분간 유저가 호출하는 API

  • 2(콘서트 좌석을 조회하는 API, 예약 API) * 1.5 ( 동시성 이슈에 의해 예약에 실패하는 케이스를 위한 재시도 계수(예측치)) = 3

분당 처리할 수 있는 트랜잭션 수: 660/s * 60s = 39600

분당 처리할 수 있는 동시 접속자 수: 39600 / 3(API 수) = 13200 명

데스트 서버를 이용시 1분당 대략적으로 13200명을 동시 처리가능하므로 10초마다 2200명의 토큰을 대기큐에서 활성큐로 전환하여 수용할 수 있다는 추론할 수 있다.

This post is licensed under CC BY 4.0 by the author.