본문 바로가기
백엔드

[우아한 티켓팅] 대기열 시스템 10,000명 부하 테스트하기

by hseong 2024. 9. 29.

들어가기에 앞서

본 게시글은 우아한 티켓팅 프로젝트의 대기열 시스템 성능 측정에 관한 이야기를 다룹니다. 이전 게시글 [우아한 티켓팅]대기열 시스템 설계하기의 내용을 바탕으로 저희 팀은 Java 자료 구조를 이용한 대기열, Redis를 이용한 대기열을 구현하였습니다. 팀의 목표 중 하나인 10,000명을 견딜 수 있는 대기열 시스템을 검증하기 위해

  1. 테스트 환경은 어떻게 구성했는지
  2. 테스트 결과는 어떗는지

에 대해 기록을 남기려 합니다.

부하 테스트 환경 구성

테스트 시나리오

테스트 대상 시스템은 대기열 시스템입니다. 다른 기능과 섞어서 성능을 측정하기 보다는 대기열 시스템 자체의 성능만을 측정하기 위해 시나리오는 다음과 같이 구성했습니다.

  1. 사용자 인증 정보 획득을 위한 사용자 로그인 JWT 토큰 생성
  2. 티켓팅의 시작점 인 공연 좌석 목록 조회
    1) 티켓팅 권한이 없는 경우 대기열 페이지 리다이렉트
    2) 티켓팅 권한이 있는 경우 시나리오 정상 종료
  3. 대기열 페이지로 리다이렉트 후 남은 순서 조회 폴링(polling)
    1) 1초 주기 폴링
    2) 남은 순서가 0 이하인 경우 시나리오 정상 종료

사용자 로그인 단계의 경우 처음에는 직접 로그인 API를 호출했으나 JWT를 직접 생성하는 것으로 시나리오를 변경했습니다. 직접 로그인 API를 호출하는 경우 사용자가 입력한 패스워드와 DB에 저장된 일방향 암호화된 패스워드 해시값을 비교하는 과정에서 응답 시간 지연이 발생했습니다. 일반적으로 티켓팅을 시도하려는 사용자는 미리 로그인하고 예매 오픈 시각이 되면 즉각 티켓팅을 시작할 겁니다.

테스트 도구 선정

테스트 도구는 Locust를 이용했습니다.

nGrinder

처음에는 이전에 사용해본 경험이 있는 nGrinder로 먼저 시도했습니다. 다만, 이전 경험과 다른 점은 단일 API만 호출하는 것이 아닌, 여러 API를 호출하는 시나리오를 테스트한다는 점이었습니다. 이를 위해 Groovy를 이용하여 직접 스크립트를 작성했습니다.

그러나 막상 테스트를 실행하니 테스트 간 순서가 뒤섞여서 실행되었습니다. Junit은 기본적으로 테스트 간의 순서를 보장하지 않는데 nGrinder에도 이 사항이 그대로 적용되고 있었습니다. nGrinder 공식 문서에서도 순서를 보장하는 테스트 작성법은 따로 찾아볼 수 없었습니다. 여러 API 호출을 테스트 하기 위해서는 하나의 테스트 안에서 모두 호출해야 했습니다. 이에 순서 보장 기능이 있는 다른 도구를 이용하고자 nGrinder 사용은 포기하였습니다.

Locust

대안으로 선택한 도구가 Locust입니다. Python은 익숙한 언어는 아니었지만 Locust 공식 문서가 잘 되어있어 스크립트를 작성하는 것이 어렵지 않았습니다. 또한, 테스트 간 순서 보장을 지원하는 SequentialTaskSet이라는 API를 통해 제가 원하는 방식대로 시나리오 테스트가 가능했습니다.

master-worker 부하 분산

처음에는 Locust를 위한 테스트 인스턴스를 EC2 t3.small 하나만 사용했습니다. 그러나 가상 사용자가 500이 넘어가면 CPU 사용률이 급격하게 치솟으면서 가상 사용자 수가 일정하게 유지되지 않는 문제가 발생했습니다. 저희는 대량의 트래픽을 테스트 해야했습니다. 따라서 안정적인 가상 사용자 유지를 위해 Locust는 master-worker 분산 환경으로 구성했습니다.

이 때, worker 인스턴스를 직접 생성하려면 Python 가상 환경 설정, Locust 스크립트 업로드, 의존성 추가 등 환경 세팅에 많은 중복 작업이 필요했습니다. 대신 AWS에서 제공하는 서비스들을 활용해서 효율적으로 worker를 생성했습니다. 과정은 다음과 같습니다.

  1. Locust master 역할을 수행할 인스턴스로부터 Locust worker 용 AMI 이미지를 생성했습니다.
  2. AMI 이미지를 이용해 자동으로 Locust worker 실행 스크립트를 실행하는 시작 템플릿을 생성했습니다.
  3. 오토스케일링 그룹을 이용하여 테스트가 필요할 때 일괄적으로 인스턴스를 생성했습니다.
  4. 부하 테스트가 진행 중이 아닐 때는 오토스케일링 그룹을 삭제하여 비용을 절약했습니다.

종합

위의 환경 구성을 종합한 그림이 다음과 같습니다.

Locust 전체 테스트 스크립트는 다음과 같습니다.

import logging
import time
from threading import Lock

import jwt
import redis
from locust import HttpUser, constant, task, SequentialTaskSet

JWT_SECRET = "thisisjusttestaccesssecretsodontworry"
global_counter = 1
counter_lock = Lock()

redis_client = redis.StrictRedis(host='localhost', port=6379)

def create_token(email):
    payload = {
        "iss": "test",
        "sub": email,
        "iat": int(time.time()),
        "exp": int(time.time()) + 3600,
        "role": "ROLE_USER"
    }
    return jwt.encode(payload, JWT_SECRET, algorithm='HS256')


class UserBehaviour(SequentialTaskSet):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.username = self.user.username
        self.performance_id = self.user.performance_id

    @task
    def start_ticketing(self):
        with self.client.get(
                f"/api/performances/{self.performance_id}/seats",
                catch_response=True,
                allow_redirects=False,
                name="1. 예매 사이트 입장."
        ) as response:
            code = response.status_code
            if code == 307:
                logging.info(f"1. [{self.username}]대기열 입장. 남은 순번 폴링 단계로 이동.")
                response.success()
            else:
                if code == 200:
                    logging.info(f"1. [{self.username}]사용자 대기열 잔류 중. 인스턴스 종료.")
                    response.success()
                else:
                    logging.error(f"1. [{self.username}]예외 발생. 예외 메시지={response.json()}")
                    response.failure(f"1. [{self.username}]예외 발생으로 인한 시나리오 종료.")
                self.user.stop()

    @task
    def waiting(self):
        while True:
            with self.client.get(
                f"/api/performances/{self.performance_id}/wait",
                catch_response=True,
                name="2. 남은 대기 순번 조회."
            ) as response:
                remaining_count = response.json()["remainingCount"]
                if remaining_count <= 0:
                    response.success()
                    logging.info(f"2. [{self.username}]대기 종료.")
                    break
                response.success()
                logging.info(f"2. [{self.username}]남은 순번={remaining_count}")
                time.sleep(1)

        self.user.stop()


class User(HttpUser):
    wait_time = constant(1)
    host = "http://localhost:8080"
    tasks = [UserBehaviour]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.username = None
        self.email = None
        self.performance_id = 5

    def on_start(self):
        unique_number = redis_client.incr('locust_counter', 2)
        self.username = f"member-{unique_number}"
        self.email = f"{self.username}@example.com"
        self.client.headers = {
            'Authorization': f"Bearer {create_token(self.email)}",
            'performanceId': "5"
        }
        logging.info(f"0. [{self.username}]토큰 발급.")

부하 테스트 결과

성능 측정_5분

앞서 정한 시나리오에 따라 테스트 시간 5분, 가상 사용자 수는 1,000명, 2,500명, 5,000명 순차적으로 증가시켰습니다. 테스트 대상은 Java 자료 구조를 이용해 만든 대기열 시스템, Redis를 이용해 만든 대기열 시스템입니다.

Locust 부하 테스트 결과는 표와 그래프로 출력됩니다. 다만 본 게시글에 모든 결과를 첨부하면 게시글 길이가 너무 길어질 것을 우려하여 다음과 같이 그래프로 정리했습니다.

성능 상 우위를 보이는 것은 Java 대기열입니다. RPS(Response Per Second)는 Java 대기열이 Redis 대기열에 비해 근소하게 우위에 있습니다. 응답 시간에서는 Java 대기열이 Redis 대기열에 경우에 따라 2배 이상 빠른 것을 확인할 수 있습니다.

가상 사용자 1,000명, 2,500명, 5,000명 중 가장 안정적인 것은 2,500명인 상황이었습니다. 2,500명일 때 95%의 요청 응답 시간이 600ms 안에 완료되는 것을 확인할 수 있었습니다.

성능 측정_5분_5,000명_스케일 아웃

이번에는 Redis의 강점을 살리기 위해 서버 인스턴스를 2대로 스케일 아웃해서 다시 한 번 부하 테스트를 진행해봤습니다.

기존 가상 사용자 5,000명 조건일 때는 대부분의 요청 응답 시간이 1초 이상 소요되었습니다. 그러나 스케일 아웃 후에는 Redis 대기열의 RPS와 응답 시간이 확연히 차이 나는 것을 확인할 수 있었습니다.

성능 측정_5분_10,000명_5초 주기 폴링

마지막으로 가상 사용자 10,000명, 폴링 주기를 1초에서 5초로 변경하여 부하 테스트를 진행하였습니다. 기존 가상 사용자 2,500명, 1초 주기 폴링일 때, 대부분의 요청을 600ms 내로 처리했으므로 가상 사용자 10,000명, 5초 주기 폴링 조건에서도 대부분의 요청을 1초 이내에 처리할 수 있을 것으로 예측했습니다.

테스트 결과 Redis 대기열의 RPS, 응답 시간 결과 지표는 다음과 같았습니다.

50%의 요청 응답 시간은 15ms, 95%의 요청 응답 시간은 160ms 안에 완료되었습니다. 최종적으로 저희의 대기열 시스템은 사용자가 10,000명, 5초 주기 폴링 조건에서 안정적으로 동작한다는 사실을 검증하였습니다. 이러한 결과에 따라 리액트 클라이언트의 폴링 주기를 5초로 고정하였습니다.

다음으로

그런데 서로 다른 조건에서 실행한 부하 테스트들의 응답 시간 그래프에서 공통적으로 스파이크가 발생하는 것을 관찰할 수 있었습니다.

저는 '응답 시간 스파이크가 발생하지?'라는 의문을 품었습니다. 다음 편에서는 이 의문을 해결하고, 응답 시간 스파이크를 줄인 과정에 대해 이야기해보겠습니다.