-
Notifications
You must be signed in to change notification settings - Fork 2
대기열 도입
현재 t3.small 인스턴스를 사용하여 서버를 배포하고 있다.
인스턴스 사양의 한계로, 가장 간단한 API 에 대해 최대 RPS 700 정도를 가진다.
이를 높이기 위한 방법으로는 두 가지 방법을 고려할 수 있다.
- Scale Out
- Scale Up
- Scale Out 같은 경우, t3.small 인스턴스를 사용하고 있는 상황에서는 Scale Out 이 아닌 Scale Up 이 적용되어야할 상황이라 생각한다. 또한 트래픽이 몰리는 시간대가 정해져 있기에 Scale Out 할 시점이 정해져 있지만, 해당 시간이 너무나도 짧으며 Scale Out 에 사용되는 비용이 과도하다 판단한다.
- Scale Up 같은 경우, 현재 교육 과정의 AWS 계정을 사용하고 있기 때문에 임의로 사양 변경이 불가능해 사용할 수 없다.
따라서 본 팀은 t3.small 인스턴스 환경은 변경하지 않되 가장 좋은 성능을 지향한다.
이를 위한 방법으로 대기열
을 구현하였다.
앞서 말했다시피 해당 사이트는 선착순 이벤트가 시작되는 시점에 트래픽이 몰린다. 예측된 트래픽을 대비해 Scale Out 을 하더라도, 트래픽이 폭주하는 짧은 순간이 지나가면 대부분의 자원은 낭비될 것이다. 따라서 본 서비스에서는 Redis 를 사용한 대기열을 통해, 해당 문제를 해결하고자 한다.
(이미지 예시)
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 값이 아닌, 식별자 사용을 지향합니다.
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 에 토큰을 집어넣습니다.
대기열 생성 시 해당 시점의 대기열에 존재하는 총 인원 수를 반환하고, polling 방식으로 token 을 통해 현재 순번을 확인할 수 있습니다.
@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 을 생성합니다.
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 를 등록한 토큰인지 함께 검증합니다.
퀴즈 테스트를 기준으로 현재 서버는 230 RPS 를 소화해낸다. 대기열을 통해서 퀴즈 제출이 초당 230 요청을 넘지 않도록 제어해야한다. 따라서 대기열은 1초에 200개의 토큰을 대기열에서 작업 가능하도록 옮긴다.
Waiting Queue 에서 매 초 N 개의 토큰을 뽑아내야한다. 이때 뽑아낸 N 개의 토큰을 어떻게 처리할 것인가에 대한 방법도 크게 2 가지가 있다.
우선 Waiting Queue 의 N 개 토큰을 Working Queue 로 옮기는 방법이다.
Waiting Queue 와 동일한 구조로 Working Queue 구성이 가능하다. 하지만 Wokring Queue 의 개별적 요소에 대해서 TTL 사용이 불가능하기 때문에 만료된 토큰들을 주기적으로 만료하는 작업이 필요하다.
ZREMRANGEBYSCORE working_queue -inf 1693046485123
위 와 같은 명령어를 통해서 Score(TimeStamp) 1693046485123 가 이하인 토큰들을 모두 삭제시킬 수 있다. 해당 명령어를 스케쥴러를 통해 주기적으로 실행시킨다면 만료된 토큰을 삭제시켜줄 수 있다.
N 개의 토큰을 Key-value 형태로 바로 저장하는 것이다. 해당 방법은 각 토큰 별로 TTL 을 설정해줄 수 있기 때문에 Redis 에게 만료된 토큰 정리를 위임한다.
위 같은 방법은 유저 수가 많아지거나 클러스터 환경에서 관리가 어려울 수 있지만, 해당 환경에서는 해당하지 않아 이 방법을 사용하였다.
- 대기열 등록 후 Token 획득
- (Loop) 해당 Token 을 통해 자신이 몇 번째인지 확인
- 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}")
실제 대기열이 없을 경우에는 Locust 기준 User 가 800 이상 생성되면 응답 시간이 크게 증가했다. 반면 대기열을 적용 시킨 경우 User 800 이상에서도 비교적 낮은 응답 시간을 가진다.
대기열을 확인하는 API 가 평균 응답 시간을 줄였다고 생각할 수 있지만, 제출 API 만의 평균 응답 시간을 비교했을 때에도 약 20% 단축을 이루어낸다. 대기열을 적용시켰을 때에는 보다 복잡한 퀴즈 제출 API 가 몰리지 않기 때문에 낮은 응답 시간을 가진다.
따라서 대기열을 적용시킨다면 같은 환경에서도 더 많은 트래픽을 소화할 수 있다.