Skip to content

대기열 도입

min9805 edited this page Aug 25, 2024 · 12 revisions

개발 방향성

현재 t3.small 인스턴스를 사용하여 서버를 배포하고 있다.

인스턴스 사양의 한계로, 가장 간단한 API 에 대해 최대 RPS 700 정도를 가진다.

이를 높이기 위한 방법으로는 두 가지 방법을 고려할 수 있다.

  1. Scale Out
  2. Scale Up
  • Scale Out 같은 경우, t3.small 인스턴스를 사용하고 있는 상황에서는 Scale Out 이 아닌 Scale Up 이 적용되어야할 상황이라 생각한다. 또한 트래픽이 몰리는 시간대가 정해져 있기에 Scale Out 할 시점이 정해져 있지만, 해당 시간이 너무나도 짧으며 Scale Out 에 사용되는 비용이 과도하다 판단한다.
  • Scale Up 같은 경우, 현재 교육 과정의 AWS 계정을 사용하고 있기 때문에 임의로 사양 변경이 불가능해 사용할 수 없다.

따라서 본 팀은 t3.small 인스턴스 환경은 변경하지 않되 가장 좋은 성능을 지향한다.

이를 위한 방법으로 대기열을 구현하였다.

대기열

앞서 말했다시피 해당 사이트는 선착순 이벤트가 시작되는 시점에 트래픽이 몰린다. 예측된 트래픽을 대비해 Scale Out 을 하더라도, 트래픽이 폭주하는 짧은 순간이 지나가면 대부분의 자원은 낭비될 것이다. 따라서 본 서비스에서는 Redis 를 사용한 대기열을 통해, 해당 문제를 해결하고자 한다.

image

(이미지 예시)

구현

1. Create Token

    public String createToken(User authenticatedUser, WaitingEnqueueBodyRequest waitingEnqueueBodyRequest) {
        String combinedData = authenticatedUser.getId() + ":"
                + waitingEnqueueBodyRequest.getSubEventId() + ":"
                + UUID.randomUUID().toString();
        return Base64.getEncoder().encodeToString(combinedData.getBytes()).replaceAll("=+$", "");
    }

대기열 진입 시 이를 위한 Token 을 생성합니다.

Token 에 엔티티의 id 값을 직접적으로 사용하는 것을 지양하고 있지만, 디버깅 및 설명 등을 위해 Id 값을 그대로 사용하였습니다. 엔티티 id 값이 아닌, 식별자 사용을 지향합니다.

2. Enqueue to Waiting Queue

    public String addWaitingQueue(long subEventId, String token) {
        redisTemplate.opsForZSet().add(WAIT_QUEUE_KEY + subEventId, token, System.currentTimeMillis());
        redisTemplate.expire(WAIT_QUEUE_KEY, 60 * 60, TimeUnit.SECONDS);
        return token;
    }

실제 대기열에 현재 서버 시간을 Score 로 SortedSet 에 토큰을 집어넣습니다.

3. 프론트엔드 조회

대기열 생성 시 해당 시점의 대기열에 존재하는 총 인원 수를 반환하고, polling 방식으로 token 을 통해 현재 순번을 확인할 수 있습니다.

4. Scheduler

    @Scheduled(fixedDelay = 1000)
    private void EventScheduler() {
        for (Long subEventId : subEventIds) {
            Set<String> tokens = queueService.popTokensFromWaitingQueue(subEventId, POP_CNT);
            queueService.addTokensToWorkingQueue(tokens);
            log.info("waiting queue pop tokens size from subEventId: {} is {}", subEventId, tokens.size());
        }
    }

스케쥴러를 통해서 1초마다 N 명을 대기열에서 POP 하고, 해당 토큰을 Key-Value 로 등록하며 30분의 TTL 을 생성합니다.

5. 검증

    public void validateToken(String token, Long id, Long subEventId) {
        String[] decodedToken = new String(Base64.getDecoder().decode(token)).split(":");
        if (decodedToken.length != 3 ||
                !decodedToken[0].equals(id.toString()) ||
                !decodedToken[1].equals(subEventId.toString())) {
            throw new WaitingInvalidTokenException();
        }

        if (redisTemplate.opsForValue().get(token) == null) {
            throw new WaitingInvalidTokenException();
        }
    }

대기열 진입 이후 사용되는 API 에서는 token 을 함께 받아 대기열에서 Key-Value 를 등록한 토큰인지 함께 검증합니다.

N 명 산정 근거

퀴즈 테스트를 기준으로 현재 서버는 230 RPS 를 소화해낸다. 대기열을 통해서 퀴즈 제출이 초당 230 요청을 넘지 않도록 제어해야한다. 따라서 대기열은 1초에 200개의 토큰을 대기열에서 작업 가능하도록 옮긴다.

부하도 테스트 ( 대기열 없을 때 퀴즈 제출 vs 대기열 있을 때 순차적 퀴즈 제출 )

대기열 없는 퀴즈 제출

image image

대기열 시나리오

  1. 대기열 등록 후 Token 획득
  2. (Loop) 해당 Token 을 통해 자신이 몇 번째인지 확인
  3. 0번째라면 퀴즈 제출
class QuizUser(HttpUser):
    wait_time = between(1, 5)

    @task
    def submit_quiz(self):
        # 1단계: /api/v1/firstcome/quiz/waitting 에 POST 요청
        token = nextToken()
        headers = {"Authorization": f"Bearer {token}"}
        response = self.client.post("/api/v1/firstcome/quiz/waiting", json={"subEventId": 1}, headers=headers)
        if response.status_code == 200:
            token_from_response = response.json().get("data", {}).get("token")  # JSON 응답에서 token 추출

            # 2단계: waitingUsers가 0이 될 때까지 반복하여 /api/v1/firstcome/quiz/waiting 에 GET 요청
            waiting_users = 1
            while waiting_users != 0:
                response = self.client.get(f"/api/v1/firstcome/quiz/waiting?subEventId=1&token={token_from_response}", headers=headers)
                if response.status_code == 200:
                    data = response.json().get("data", {})  # JSON 응답을 파싱
                    waiting_users = data.get("waitingUsers", 1)
                    if waiting_users != 0:
                        time.sleep(1) # waitingUsers가 0이 아닐 때 1초 대기

            print("All users are ready!")
            # 3단계: /api/v1/firstcome/quiz 에 POST 요청
            quiz_response = self.client.post("/api/v1/firstcome/quiz", json={
                "answer": "11.5",
                "subEventId": 1,
                "token": token_from_response
            }, headers=headers)

            if quiz_response.status_code == 200:
                print("Quiz submitted successfully!")
            else:
                print(f"Quiz submission failed: {quiz_response.status_code}")
        else:
            print(f"Failed to get token from quiz/waitting: {response.status_code}")

image image image

결론

실제 대기열이 없을 경우에는 Locust 기준 User 가 800 이상 생성되면 응답 시간이 크게 증가했다. 대기열을 적용시켰을 때에는 보다 복잡한 퀴즈 제출 API 가 몰리지 않기 때문에 User 800 이상에서도 비교적 낮은 응답 시간을 가진다.

따라서 대기열을 적용시킨다면 같은 환경에서도 더 많은 트래픽을 소화할 수 있다.