티스토리 뷰

안녕하세요 강정호입니다.

 

이번 포스팅은 제가 콘서트 예약시스템에서 구현한 대기열에 대한 설계에 대해서 작성해보겠습니다.

 

[대기열이란?]

대기열은 서버에 대용량 트래픽이 몰릴 때를 대비해서 서버의 부하를 일정 수준으로 유지하기 위해 만듭니다.

1000명이 동시에 좌석 예약을 하기 위해 요청을 보냈을 때, 선착순 50명만 예약이 가능하도록 하고 나머지 사용자는 대기열에 들어가게 됩니다. 이렇게 되면 서버의 부하를 줄여서 트래픽을 제어할 수 있다는 장점이 있습니다. 대기열에 있는 사용자의 요청은 일정 시간이 지나면 예약이 가능한 상태로 변경되어 고객들은 콘서트 예매를 할 수 있습니다.

 

이렇게 하여 단시간에 서버에 발생하는 부하를 줄여서 안정적으로 서버를 유지할 수 있습니다.

 

[대기열 구현 방법]

제가 생각한 대기열 구현 방법은 2가지가 있습니다.

 

1. 은행 창구방식 + DB를 활용한 대기열 구현

은행창구방식이란?

은행 창구에서 은행원이 고객을 1명씩 응대하듯이, 고객 1명의 업무가 종료되면 다음 고객이 기회를 갖게 되는 방식입니다.

장점

딱 정해진 사용자수만 예약이 가능한 상태입니다. 그래서 서버의 부하를 일정 수준 이상을 넘지 않는다는 장점이 있습니다. 

단점

대기열에 있는 사용자는 기약없이 기다려야 합니다. 예약이 완료될 때마다 대기열에 있는 사용자들이 1명씩 예약 가능한 상태로 변경이 됩니다. 그렇다보니 대기 중인 사용자는 언제까지 대기해야 하는지 모릅니다. 그래서 대기열에서 이탈하는 고객이 발생할 가능성이 높습니다.

@Transactional
  public TokenResponse insertQueue(TokenRequest request) {

    Token tokenEntity = null;

    // 0. 사용자 생성
    User user = userManagerImpl.createUser();

    // 1. 토큰 발행
    String token = tokenGenerator.generateToken(user.getUserId());

    // 2. 대기 순번조회
    long waitNo = tokenReader.selectNextWaitNo();
    //if(waitNo == 0) waitNo++;

    // 3. 대기 상태조회
    ProgressStatus status = tokenReader.getCurrentQueueStatus();

    // 4. Token 엔티티 생성(현재 상태에 따라서 토큰 만료시각을 다르게 설정)
    if(status == ProgressStatus.ONGOING) { // 토큰 만료시간은 10분 후
      tokenEntity = Token.builder()
          .user(user)
          .token(token)
          .waitNo(waitNo)
          .progressStatus(status)
          .createdAt(LocalDateTime.now())
          .expiredAt(LocalDateTime.now().plusMinutes(10)) //토큰이 10분간 유효함.
          .build();
    } else if(status == ProgressStatus.WAIT) { // 대기상태이기 때문에 토큰의 만료시간을 설정하지 않음.
      tokenEntity = Token.builder()
          .user(user)
          .token(token)
          .waitNo(waitNo)
          .progressStatus(status)
          .createdAt(LocalDateTime.now()) // 대기상태이기 때문에 토큰의 만료시간을 설정하지 않음.
          .build();
    }


    // 4. 발급한 토큰 테이블에 저장
    Token savedTokenEntity = tokenWriter.insertTokenTable(tokenEntity);
    user.setToken(token);

    return TokenResponse.from(savedTokenEntity);
  }

 

위와 같이 DB를 활용해서 대기열을 구현했습니다. 사용자 생성 -> 토큰 발급 -> 대기순번 조회 -> 토큰 상태 결정(대기 or 예약가능) -> 토큰 테이블에 저장. 이 순서로 프로세스를 타고 대기열에 진입합니다.

 

 

DB를 활용한 대기열에서 중요한 점은 바로 "일정 시간별로 사용자(토큰)의 상태를 변환" 시켜주는 것입니다. 예약이 완료된 고객이 있다면 대기열에서 1명의 고객을 예약 가능한 상태로 변경해줘야 합니다. 그 역할을 Spring Scheduler를 사용하여 구현했습니다.

package com.tdd.concert.domain.token.component;

import java.time.LocalDateTime;
import java.util.List;

import com.tdd.concert.domain.token.model.Token;
import com.tdd.concert.domain.token.repository.TokenCoreRepository;
import com.tdd.concert.domain.token.status.ProgressStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@Slf4j
@RequiredArgsConstructor
public class TokenScheduler {

    private final TokenCoreRepository tokenCoreRepository;

    @Scheduled(cron = "0 * * * * *")
    @Transactional
    public void updateToken() {

        long ongoingCount = tokenCoreRepository.getProgressStatusCount(ProgressStatus.ONGOING);
        long waitCount = tokenCoreRepository.getProgressStatusCount(ProgressStatus.WAIT);

        if(ongoingCount < 50) {
            int availableCount = (int) (50 - ongoingCount);

            for(int i = 0; i<Math.min(availableCount, waitCount); i++) {
                long nextWaitNo = tokenCoreRepository.getNextPriorityWaitNo(ProgressStatus.WAIT); // 가장 우선인 다음 대기순번 고객

                Token token = tokenCoreRepository.findByWaitNo(nextWaitNo);

                if(token != null) {
                    // 토큰의 속성을 변경하고 저장하는 부분
                    token.setExpiredAtAndStatus(LocalDateTime.now().plusMinutes(10)
                                                , ProgressStatus.ONGOING);
                }

            }
        }
    }


    @Scheduled(cron = "0 * * * * *")
    @Transactional
    public void dropToken() {
        List<Token> tokenList = tokenCoreRepository.findExpiredTokenList(ProgressStatus.ONGOING);

        for(Token token : tokenList) {
            if(token.getExpiredAt().isBefore(LocalDateTime.now())) {
                token.dropToken();
            }
        }
    }

}

각 토큰에는 ProcessStatus(예약진행상태)로 "대기", "예약진행중", "예약완료"가 있습니다. 스케쥴러는 현재 추가로 들여보낼 수 있는

사용자의 수를 계산하고 대기열에서 그 수만큼 사용자의 상태를 대기 --> 예약진행중으로 변경합니다.

스케쥴러는 1분마다 돌게 설정을 하였습니다.

 

스케쥴러로 일정주기별로 대기열을 관리하게 된 이유??

이 부분이 고민이 되는 지점이었습니다. 1분 주기로 스케쥴러가 돌기 때문에 이미 자리가 났음에도 불구하고 대기중인 사용자는 스케쥴러가 실행되는 다음 주기까지 대기해야 하기 때문입니다. 그럼에도 불구하고 스케쥴러로 구현한 이유는 아래와 같습니다.

1) 스케쥴러는 토큰의 상태값을 "대기" --> "예약진행중" 상태로 변경하는 역할

2) 스케쥴러는 예약 중인 사용자 중에 10분이 지난 토큰은 강제로 만료시키는 역할도 한다.

 

이렇게 2가지 역할을 하다보니 대량의 사용자들의 상태를 한번에 변경하고, 구현 난이도가 상대적으로 쉬운 스케쥴러를 채택했습니다.

 

그럼 더 좋은 방법은 없을까?

이벤트 리스너 or 메시징 방식을 사용하여 토큰의 상태값을 변경하는 방법도 효과적이라고 생각합니다.

그 이유는 예약이 완료되었을 때 빈 자리가 생기면 그 즉시 이벤트 리스너가 토큰의 상태값을 "대기" --> "예약중"으로 변경할 수 있기 때문입니다. 실시간으로 상태값이 변경 가능하여 사용자들은 대기시간이 감소합니다.

 

 

 

2. 놀이동산방식 + Redis Sorted set을 활용한 대기열 구현

놀이동산 방식이란?

일정 주기마다 N명씩 놀이동산에 입장하는 방식처럼 일정 주기가 되면 대기열에 대기 중인 사용자들을 N명씩 들여보내는 방식

장점

사용자들이 일정 시간만 대기하면 된다. 

예를 들면 10초마다 250명씩 예약가능하도록 상태변경 한다면, 1200번째 사용자는 약 50초 후에 예약이 가능하다

단점

예약이 완료되어 나가는 사용자보다, 예약하기 위해 진입하는 사용자가 더 많은 경우 서버에 부하가 생기게 된다.

 

Redis를 사용한 이유?

1) DB의 부하를 줄여준다 

DB는 비용이 비싼 자원입니다. 그리고 상대적으로 속도도 느립니다. 반면에 Redis는 인메모리 방식의 저장소이기 때문에 속도가 매우 빨라서 대용량 트래픽을 감당하기 용이합니다

2) 대기열을 진입 순서대로 관리 가능

Redis의 Sorted Set을 사용하면 Score를 사용해서 사용자들이 진입한 시각으로 우선순위를 결정할 수 있습니다.

따라서 대기 중인 사용자를 예약가능한 상태로 변경하기도 용이합니다.

3) 콘서트별로 각각 다른 대기열을 만들어낼 수 있다

Sorted Set에서 대기열의 key를 "waiting:concert:1", "waiting:concert:2" ....  이렇게 콘서트ID별로 생성하여

대기열을 관리할 수 있습니다. 그러면 인기 많은 콘서트와 인기가 없는 콘서트 예매가 동시에 열려도 서로의 영향을 받지 않고

대기열을 관리할 수가 있습니다.

 

https://github.com/wwwkang8/hhplus/blob/main/week3/src/main/java/com/tdd/concert/domain/token_redis/infra/RedisTokenCoreRepositoryImpl.java

 

hhplus/week3/src/main/java/com/tdd/concert/domain/token_redis/infra/RedisTokenCoreRepositoryImpl.java at main · wwwkang8/hhplus

항해플러스 백엔드 주차별 과제 모음. Contribute to wwwkang8/hhplus development by creating an account on GitHub.

github.com

위의 링크는 Redis 대기열, 예약가능열에 토큰을 insert 하는 로직이 있습니다.

 

Redis도 마찬가지로 토큰을 관리해주는 스케쥴러가 필요합니다.

- 토큰 상태변경 : 10초마다 750명을 대기열-->활성화열(예약가능한열)로 이동시켜줍니다.<-- 스케쥴러가 필요

- 토큰 만료 : Redis의 sorted Set에 추가할 때 ttl을 설정(expire 되는 시각)하여 시간이 지나면 자동으로 삭제됨.

@Component
@RequiredArgsConstructor
@Slf4j
public class RedisTokenScheduler {

  private final RedisTokenCoreRepositoryImpl redisTokenCoreRepositoryImpl;
  private final ConcertCoreRepositoryImpl concertCoreRepository;
  private final RedisTemplate<String, String> redisTemplate;
  private ZSetOperations<String ,String> zSetOperations;

  private final long POP_CNT = 750;

  @PostConstruct
  public void init() {
    this.zSetOperations = redisTemplate.opsForZSet();
  }

  /** 여유 자리가 생기면 WaitingQueue에서 끄집어 내서 WorkingQueue에 넣어주는 것 */
  @Scheduled(cron = "*/10 * * * * *")
  @Transactional
  public void updateWaitingQueue() {

    List<Long> concertIdList = concertCoreRepository.concertList();

    for(Long concertId : concertIdList) {

     List<String> tokenList = redisTokenCoreRepositoryImpl.popTokensFromWaitingQueue(concertId, POP_CNT);
     redisTokenCoreRepositoryImpl.addTokenListWorkingQueue(concertId, tokenList);

     for(String token_n : tokenList) {
       redisTokenCoreRepositoryImpl.addWorkingQueue(concertId, token_n);
       log.info("[RedisTokenScheduler] 콘서트ID : "+concertId + ", 토큰 : " + token_n + " WorkingQueue 진입 완료");
     }
    }
  }


}

 

10초마다 750명을 대기열->활성화열로 이동시켜주는 근거?

- 1명의 유저가 예약조회부터 결제까지 걸리는 시간 : 평균 1분

- DB에 동시에 접근할 수 있는 트래픽의 최대치 : 1초에 약 1,000TPS ⇒ 1분에 60,000TPS

- 1분간 유저가 호출하는 API 횟수 :

4(예약가능날짜조회, 좌석조회, 좌석예약, 결제처리) * 2(동시성 이슈에 의해 예약실패하여 API 재처리) = 8

 

- 1분당 처리할 수 있는 동시접속자 수 = 7,500명

⇒ 최종 결론 : 10초마다 750명을 예약가능한 상태로 전환.

 

 

놀이동산방식 + Redis 대기열로 구현했을 때의 한계점?

예약가능한 사용자가 계속해서 증가하게 될 때 서버의 부하가 커질 것이다.

이런 경우에 어떻게 문제를 해결할 것인가?

이 부분은 추가로 고민을 해서 개선해봐야겠습니다

댓글