Search

7주차 - 대기열 토큰 Redis 활용 보고서

생성일
2025/04/22 14:33
태그

배경

분산 락과 캐시를 도입하면서 Redis를 구축하고 사용하게 되었는데, 이왕 도입된 Redis를 조금 더 활용하여 콘서트의 대기열 토큰을 Redis로 변경하면 데이터베이스의 부담도 줄이고 성능적으로도 더 빠르게 구현할 수 있을 것이다.
대기열 토큰을 대기열 구현 방식이나 구현할 Redis의 자료구조를 선택하는 것, 대기열을 Redis로 어떻게 구현할 수 있을 지 설계를 해보고, 직접 구현해보자.

대기열 토큰 Redis로 변경하기

대기열 토큰 만료 방식 변경

기존의 대기열 토큰은 소위 은행창구 방식이라 불리는, 활성화된 토큰의 수를 엄격하게 관리하는 방식으로 구현했었다. 하지만 HTTP 통신은 기본적으로 stateless 방식이므로, 활성화된 토큰이 제대로 사용되고 있는지 추적하기 어렵다. 이를 추적한다하더라도 매 접근 시마다 접근 시간을 갱신하는 작업이 발생하므로 비효율적이게 된다.
반대로 추적을 포기한다면, 활성화된 토큰의 수를 줄이기 위해 활성 토큰의 만료 조건을 설정할 수 있는 방법이 몇 가지 없다.
1.
활성화 시점부터 xx초 이후 만료
2.
결제를 마치면 활성화된 토큰을 만료
또한 위의 두 조건을 모두 지정하더라도, 다수의 유저가 들어와 결제를 진행하지 않고 만료시간까지 버티게된다면(허수 사용자), 서버에는 처리 여유가 있지만 대기중인 다른 사용자는 이전 사용자의 만료 시간이 끝날 때까지 기다려야하는 상황이 발생할 수 있다. 그렇다고 만료 시간을 짧게 가져가면, 결제를 하지도 못했는데 토큰이 만료되어 좋지 않은 사용자 경험을 제공하게 될수도 있다.
따라서 은행창구 방식의 대기열 토큰 관리는 추적을 하든, 추적을 하지 않든 여러모로 비효율적이고 관리가 어렵다는 문제가 있다. 때문에 놀이창구 방식이라 불리는, 서버의 트래픽 용량이 허용하는 선에서 대기 토큰을 지속적으로 활성화시켜 사용자를 서버에 부어주는 방식을 사용해보자.

적절한 자료구조 선택하기

대기열 토큰의 요구사항을 확인하고 분석해, 대기열 토큰 설계에 가장 효율적인 자료 구조를 선택해보자.
놀이공원 방식의 대기열의 요구사항은 다음과 같이 정의할 수 있을 것 같다.
1.
활성화된 토큰과 아직 대기중인 토큰이 존재한다.
2.
특정 주기마다 X개의 비활성화 토큰을 활성화 시켜야하며, 먼저 저장된 대기열 토큰이 먼저 활성화 되어야 한다.
3.
대기 토큰이 대기열의 몇 번째에 있는지 확인할 수 있어야 한다.
4.
대기 토큰과 활성화 토큰 모두 UUID를 통해 토큰을 한 번에 조회할 수 있어야 한다.
위 요구사항 중 1번은 Redis를 통해 대기 토큰 저장소와 활성화 토큰 저장소를 분리하여 구현하면 된다. 하지만 2번과 3번의 요구사항에서 몇 번째인지 알아야한다는 조건 때문에, 대기 토큰 저장소에 한해서는 Redis 자료구조 내부적으로 순서를 가지고 있어야 한다.
따라서 대기 토큰 저장소는 내부적으로 순서가 있는 Lists나 Sorted Set을 사용해야하는데, 다만 자신의 대기 순서가 몇 번째인지 확인할 수 있어야 하기 때문에 Sorted Set을 사용하는 것이 가장 적합해보인다.
그에 비해 활성화 토큰 저장소는 별다른 제약 조건이 없기 때문에, 저장하는 용량을 최소화하면서도 key에 대한 만료 시간을 설정하기 위해 단순히 key-value로 저장하는 것이 적합하다고 생각된다.

자료구조 사용해보기

기능을 개발하기 전에 redis에서 직접 해당 자료구조와 여러 필요한 명령어들을 사용해보면서, 어떤 명령어가 있고 어떻게 동작하는지에 대해 먼저 파악해보자.
Redis 기본 키에대한 명령어
set
get
ZSet
zadd
zrank
zscore
zrange
zrem
zremrangebyrank
zremrangebyscore
Redis는 내부적으로 skiplist를 통해 sorted set을 구현하는데, 해시 테이블을 두어 각 레벨을 저장하고 각 레벨별로 일정 크기를 skip한 연결 리스트(Linked-List)로 구현되어 있다. 레벨이 높을수록 skip을 많이하게 되고, 따라서 조회 수행 시 높은 레벨부터 낮은 레벨로 내려가면서 값의 범위를 좁혀나가 시간 복잡도 O(log n)을 보장한다.

요구사항 분석

우선 아래의 여러가지 이유로 인해 기존의 도메인 모델을 그대로 유지하는 것이 어렵고, 때문에 Repository DIP를 통해 구현 바꿔치기로 추상 인터페이스를 재활용하는 것은 어렵다.
기존에 구현된 대기열 토큰 관련 비즈니스 로직의 상당수가 불필요해지거나 변경된다.
기존 구현은 Repository에 비즈니스 로직을 넣지 않기 위해, id를 통해 토큰을 불러온 다음 도메인 계층에서 대기 번호를 계산했다. 하지만 이를 DIP로 기존 로직 그대로 Redis만 바꿔치기 하기에는 Redis의 ZSet을 전혀 활용하지 못하게 된다.
대기열 토큰에는 만료 상태나 expiredAt을 넣었지만, 이를 대기 토큰과 활성 토큰 저장소를 분리하고 스케줄러와 TTL을 통해 사용할 예정이므로 둘 다 불필요한 필드가 된다.
은행창구 방식에서 놀이공원 방식으로 구현 방식이 변경되었기 때문에, 그에 따라 토큰에 필요한 데이터나 저장하기 위한 형태도 달라지게 된다.
따라서 대기열 토큰을 도메인 모델부터 기능까지 다시 구현하고 테스트 또한 새롭게 작성해야한다…
1.
대기 토큰
위에서 정리한 내용처럼, 대기 토큰은 ZSet을 활용해 대기 토큰에 대한 요구사항을 분석해보자.
대기열 구현(순서보장)
대기열 진입 순서에 따라, zadd 시 진입 시간을 score로 부여해 내부적으로 토큰의 순서를 부여한다.
토큰 활성화
특정 주기마다 zrange와 zrem 명령어를 통해 X개의 토큰을 활성 토큰으로 넘겨 활성화시킨다.
대기 번호 및 대기시간 확인
key로 사용한 특정 UUID를 zrank 명령어로 대기열에서 몇 번째인지를 반환한다.
만료
ZSet의 개별 member에는 만료 시간을 설정할 수 없기 때문에, 시간으로 사용하는 score를 통해 매 주기마다 만료 시킨다.
대기 토큰의 기본 만료 시간은 1시간으로 한다.(대기 토큰 생성 시 score + 3600)
저장
ZSet의 key값으로는 “waitingToken“을 사용한다.
각 토큰별 member 값은 tokenUUID를 사용하고, 저장 시간을 점수로 산정하여 score에 저장한다.
2.
활성화 토큰
value를 통해 활성화 토큰에 대한 요구사항을 분석해보자.
활성 토큰 확인
get 명령어를 통해 해당 UUID로 저장된 활성 토큰이 존재하는지 확인한다.
만료
redisTemplate의 expire 기능을 활용해 활성 토큰의 TTL을 지정한다.
활성화 토큰의 기본 만료시간은 30분으로 한다.
저장
Set의 key 값으로는 tokenUUID를 사용한다.

기능 설계하기

1.
테이블로 관리하던 대기열 엔티티 및 대기열 상태 Enum 삭제
대기열 토큰을 Redis로 관리하게 되면서, 대기열 엔티티와 상태값이 필요없어지게 된다.
2.
필요 기능 및 동작에 대한 의사 코드 작성
대기 토큰 생성 및 대기열 진입
UUID 생성
현재 시간을 기준으로 score 계산
대기 토큰 저장소에 추가
자신의 대기번호 및 예상 대기시간 확인
전달받은 토큰 UUID를 통해 대기 토큰 저장소에서 앞에 몇 명 있는지(랭킹/등수) 조회 및 예상 대기시간 계산
대기번호 및 예상 대기시간 반환
대기열 내 대기 토큰 중 만료 시간이 지난 토큰에 대해 삭제
현재 시간을 기준으로 score 계산
score보다 작은 값을 가지는 모든 대기 토큰 삭제
대기열 내 대기 토큰 중 일부 활성화(스케줄러가 호출)
대기 토큰 저장소에서 대기 토큰 X개 조회
활성화 토큰 저장소에 조회한 대기 토큰 추가 및 만료시간 설정
활성화에 성공한 대기 토큰을 대기 토큰 저장소에서 삭제
조회 직후 바로 삭제하지 않는 이유는, 1.조회 → 2.삭제 → 3.활성화 토큰 저장소에 추가의 동작에서 2번까지 성공 후 3번에 실패하거나 Redis 내부적으로 2번 실행은 성공했지만 HTTP 응답을 받지 못해 readTimeout이 발생하는 등의 상황에서 대기 토큰만 삭제되는 경우가 발생할 수 있기 때문이다.
활성화 토큰인지 확인
전달받은 토큰 UUID를 통해 활성화 토큰 저장소에 존재하는지 확인
활성화 여부(활성화 저장소에 존재여부) 반환

주기적으로 활성화시킬 토큰 수 계산하기

Redis를 통해 대기열 토큰을 구현했으니, 이제 주기적으로 얼마만큼의 토큰을 활성화시키는 것이 서버에 큰 부담을 주지 않으면서도 효율적으로 처리할 수 있는지 계산해보자.

전제조건

Redis의 부하는 계산하지 않는다.
MAU는 5,000명이다(가정 / 콘서트 좌석수 50개 감안하여 설정)
매 콘서트마다 평균적으로 2개의 콘서트 일정이 열린다(가정)
평균적으로 60%의 사용자가 매일 예매를 시도한다.(가정)
임의의 사용자가 콘서트 예약의 성공까지 평균적으로 예약을 2.5번 실패한다(가정) (콘서트 예약 실패 시 콘서트 좌석 조회 후 콘서트 예약 반복)
조회 쿼리는 2ms, CUD 쿼리는 10ms가 소요된다(가정)

사용자 호출 API에 따른 발생 쿼리 계산

사용자별 API 평균 호출 횟수
콘서트 조회 : 1회
대부분 콘서트 예약을 시도하는 사용자는 원하는 콘서트를 정하고 들어옴(예상)
콘서트 일정 조회 : 2회
콘서트 일정이 대부분 이틀 진행되므로, 각 콘서트 일정마다 조회 1번씩 발생(예상)
콘서트 좌석 조회 : 7회
비즈니스 로직상 예약된 좌석을 보여주기 때문에, 빈 좌석을 바로 선택 → 예매 * 콘서트 일정(2일) * (평균 실패 횟수 2.5 + 1)
콘서트 예약 : 3.5회
평균 예약 실패횟수 2.5 + 1
콘서트 결제 : 1회
결제는 한 번에 전부 진행
사용자별 평균 쿼리수 계산
조회 10회
콘서트 예약 : (유저 조회 + 좌석 선점) * 3.5 + 콘서트 예약 = 8회
콘서트 결제 : 유저 조회 + 예약 조회 + 포인트 차감 + 결제 + 좌석 매진 + 예약 만료 시간 삭제 = 6회
총 쿼리수 = 24회 (조회 15.5회 / CUD 8.5회)

QPS 추정

MAU 5,000 * 60% = 3,000명
3000 * 24 = 72,000회
72000 / (30 * 24 * 3600) = 0.0417
최대 QPS = 2 * 0.0417 = 0.0834

TPS 추정

최대 QPS 0.0834
조회 : 0.0834 * 15.5 / 24 = 0.0539회 → 0.0539 * 2ms = 0.1077ms
CUD : 0.0834 * 8.5 / 24 = 0.02953 회 → 0.02953 * 10ms = 0.2953ms
평균 쿼리 실행시간 : 0.1077 + 0.2953 = 0.4030ms
1000 / 0.4030 = 2481.3895 TPS
따라서 위 시스템은 5,000명의 사용자가 있다고 가정하면, 1초에 약 2500개의 트랜잭션이 수행된다.

MySQL TPS

정식 스펙으로 계산하는 경우 토큰을 받는대로 부어도 전혀 문제가 없다. 하지만 학습을 위해서 최대 TPS를 낮추어 가정하고 계산해보자.
임영웅 콘서트 : 좌석 수 = 9만 / 최대 대기열 = 43만
우리의 서비스 : 좌석 수 = 50
우리가 임영웅 콘서트의 1/5만큼의 수요를 가지고 있다고 가정 후, 위 두 수치를 감안해 스케일 조정하여 MySQL의 TPS를 1000배 낮추어 계산
MySQL의 사용자 별 처리 가능 TPS를 살펴보면, TPS가 2500인 경우 → 최대 Connection 수 = 대략 20

초당 최대 토큰 처리 가능 수량

최대 성능의 60%만 가용한다고 가정하면, 20 * 60% = 12
따라서 초당 약 12개의 Connection 연결을 수행할 수 있다.(N = 1 / M = 12)

참고