대기열 시스템 설계
대기열 시스템 설계
목차
개요
- 일시적으로 많은 트래픽이 발생하는 경우에 과도한 트래픽의 유입을 방지해 주는 대기열 시스템
- Redis를 사용하여 효율적이고 확장 가능한 대기열 시스템을 설계
시스템 설계
기존 DB 시스템
- 문제점
- 과도한 트래픽 처리 불가: 일시적으로 많은 트래픽이 발생하면 DB 처리 성능 저하
- 확장성 부족: DB 서버의 확장이 한계가 있다
Redis 기반 시스템
선정 이유
- 고성능: 메모리 기반의 데이터 저장소 매우 빠른 읽기/쓰기 속도를 제공
- 확장성: 클러스터링을 통해 쉽게 확장이 가능하여 많은 트래픽을 효율적으로 처리할 수 있다
- 다양한: 데이터 구조 지원 Redis는 리스트, 셋, 해시맵 등 다양한 데이터 구조를 지원하여 대기열 관리에 유연성을 제공
- 안정성: Redis는 지속적인 데이터 백업과 복제를 통해 데이터의 안정성을 보장
사용한 자료구조
- waiting queue: sorted set
- Score를 기준으로 정렬된 중복을 허용하지 않는 고유한 값들을 관리하는 컬렉션
- 대기열에 입장한 순서대로 참가열에 입장해야 하므로 사용
key: WAITINGmember: {token}score: {timestamp}
- active queue: set
- 중복을 허용하지 않는 고유한 값들의 컬렉션
key: active:{token}member: {token}- token을 key로 가진 각각의 set에 TTL을 적용
구현 방식
- 유입된 순서대로 스케쥴러를 통해 지정한 인원수만큼 대기열로 진입시킨다
한 사이클당 진입시키는 인원은 대략적으로 정의한 값이고 추후 운영 환경으로 세팅 후 부하 테스트 등을 통해 조금 더 정확한 수치를 도출해 지정하도록 한다
- 스케줄러 한 사이클당 진입시키는 인원수 계산
- 유저가 예약완료 결제까지 걸리는 시간: 대략 1분
- DB가 처리하는 트랜잭션 수 (TPS): 약 1000 TPS
- 이 수치는 테스트 수치이므로, 실제 운영환경에서 부하테스트를 통해 의미있는 수치를 도출할 필요 있음
- 유저가 예약완료 결제까지 호출하는 API 수: 대략 3 (ex: 좌석조회, 예약, 예외로 인한 재시도 등등)
- 계산 과정
- 분당 처리 가능한 트랜잭션 수:
- 1000 TPS * 60초 = 60000 트랜잭션
- 분당 처리 가능한 유저 수:
- 분당 처리하는 트랜잭션 수 / 유저가 호출하는 API 수
- 60000 트랜잭션 / 3 API = 20000 유저
- 10초마다 처리 가능한 유저 수:
- 20000 유저 / 60초 * 10초 = 3333 유저
- 분당 처리 가능한 트랜잭션 수:
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
// 토큰검증 시 토큰이 없다면 신규 토큰 발급 // 토큰이 만료되는 일시는 유효한 API 를 호출할때마다 갱신해준다 public Waiting checkToken(String reqToken) { String token = reqToken; if (reqToken == null) { // 토큰없음 -> 신규 진입 token = UUID.randomUUID().toString(); // 활성화 수 Long activeCnt = waitingRepository.findQueueCnt(ACTIVE_KEY_PREFIX); if (activeCnt < ENTRY_LIMIT) { // 참가열 즉시 진입 waitingRepository.addActiveQueue(token); return Waiting.builder() .token(token) .status(WaitingStatus.PROCEEDING) .build(); } else { // 대기열 진입 waitingRepository.addWaitingQueue(token); return this.getWaitingInfo(token); } } else { // 토큰 존재 -> 이미 대기중인 상태, 대기열 정보 조회 return this.getWaitingInfo(token); } } // 예상 입장 시간 계산: (내 순번 / 스케쥴러 한주기당 진입하는 인원) * 스케쥴러 반복 주기(초) private Waiting getWaitingInfo(String token) { Long rank = waitingRepository.findWaitingRank(token); long waitingTime = (long) Math.ceil((double) (rank - 1L) / ENTRY_LIMIT) * 10; LocalDateTime timeRemaining = LocalDateTime.now().plusSeconds(waitingTime); return Waiting.builder() .token(token) .rank(rank) .accessDatetime(timeRemaining) .status(WaitingStatus.WAITING) .build(); } // sorted set의 score로 지정한 진입 시간을 이용해서 진입한 순서대로 토큰을 pop을 이용하여 별도 삭제로직없이 처리 @Override public List<String> popWaitingTokenList(Long range) { Set<ZSetOperations.TypedTuple<String>> typedTuples = zSetOperations.popMin(WAITING_KEY, range); return Optional.ofNullable(typedTuples) .map(set -> set.stream() .map(ZSetOperations.TypedTuple::getValue) .toList()) .orElse(null); } // popWaitingTokenList에서 추출한 참가열 진입가능한 토큰들을 pipline을 이용하여 한번에 추가 @Override public void activateTokens(List<String> tokenList) { redisTemplate.executePipelined((RedisCallback<Object>) connection -> { tokenList.forEach(token -> { String key = ACTIVE_KEY_PREFIX + token; connection.setCommands().sAdd(key.getBytes(), token.getBytes()); connection.commands().expire(key.getBytes(), 300); }); return null; }); }
- 스케줄러 한 사이클당 진입시키는 인원수 계산
- active token 만료처리
- 대기열에서 참가열로 진입할때 지정해준 TTL을 가지고 기본적으로 만료시키고 유의미한 API(ex: 좌석 조회)를 호출하면 토큰 검증 로직에서 TTL을 갱신해준다
1 2 3 4 5 6 7 8
public void checkWaitingStatus(String token) { if (Boolean.TRUE.equals(waitingRepository.isActiveToken(token))) { // 유효한 토큰일 경우 TTL을 연장 waitingRepository.refreshTimeout(token); } else { throw new WaitingException(WaitingExceptionEnums.TOKEN_EXPIRED); } }
This post is licensed under CC BY 4.0 by the author.