Skip to content

배포 환경에서의 선착순 이벤트 성능 모니터링

SEUNGUN CHAE edited this page Aug 28, 2024 · 14 revisions

팀 어썸오렌지는 다양한 구현방안 분석 결과를 바탕으로 "Redis"와 "LuaScript"를 결합하여 선착순 이벤트 참여 기능을 구현하는 방안을 채택했습니다.

이는 다양한 방안에 대한 실험 결과 동시성 문제를 방지하면서도 가장 응답 시간 및 처리량이 우수했기 때문입니다.

또한 17시마다 선착순 이벤트가 시작된다는 요구사항에 따라, 16시가 되면 오토 스케일링을 통해 3개의 인스턴스를 추가로 생성하여, 순간적으로 폭등할 선착순 이벤트 관련 트래픽을 로드 밸런싱을 통해 여러 인스턴스로 분산하는 환경을 구축했습니다.

로드 밸런싱 알고리즘은 "라운드 로빈"을 채택했습니다.

이는 오토 스케일링을 통해 태어나는 추가 인스턴스가 동일한 성능(t3.small)과 설정(공통된 시작 템플릿)을 갖고 있기에 균등한 트래픽 분배가 적합하기 때문입니다.

이제 팀 어썸오렌지가 배포 환경의 Springboot 서버의 선착순 이벤트 기능을 테스트한 결과에 대해 설명드리겠습니다.

Background

  • 배포 환경(EC2 t3.small)은 로컬(M1 Pro)보다 훨씬 서버 스펙이 낮습니다. (RAM 2GB, Disk 8GB)
  • 또한, Apache JMeter로 Spike Test를 실행하던 도중 소프티어 부트캠프 교육장 WiFi와 자택 WiFi, 그리고 5G 등 Spike Test를 실행하는 네트워크 환경에 따라 처리량과 응답 시간의 편차가 굉장히 크다는 것이 확인되었습니다.
  • 위와 같이 네트워크 환경으로 인한 편차를 최소화하기 위해 테스트 실행 전용 EC2 인스턴스를 생성하여 해당 인스턴스에서 테스트를 실행하는 계획을 수립했습니다.
  • 그러나, 테스트용 EC2 역시 t3.small이었기에 RAM의 크기가 2GB로 작았고, Jmeter를 실행하려면 1GB 이상의 Heap Memory가 요구되는 상황이었습니다.
  • 실제로 Jmeter를 통해 EC2 안에서 Spike Test 실행 시 CPU 사용이 90%를 초과했다는 경고 메시지와 함께 인스턴스와의 연결이 끊어지고 있었습니다.
  • 따라서 로컬과 달리 테스트용 EC2에서는 Jmeter 대신 더 가벼운 javascript 기반의 k6을 활용하여, 메모리와 CPU를 절약하며 Spike Test를 실행하고 기록했습니다.

테스트 시나리오 수립

image

  • 팀 어썸오렌지의 선착순 이벤트는 일정 시각이 되면 활성화되는 4가지 카드 중 정답 카드를 뒤집는 형태이며, 정답 카드를 먼저 뒤집은 순서대로 당첨 여부를 판정하고 있습니다.
  • 하나의 카드를 뒤집는 데 걸리는 시간은 약 0.5초입니다.
  • 즉 클라이언트는 "특정 시각에", "최대 4장의 카드를", "0.5초 간격으로 선택하는" 요청을 전송하는 것입니다.
  • 즉 서버 입장에서 선착순 이벤트의 동시 사용자 수가 1000명이라면 2초 전후로 최대 4000번의 요청을 소화하는 것입니다.

k6.js

import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import { Rate } from 'k6/metrics';

// Custom metric to track failures
export const failureRate = new Rate('failed_requests');

// Load JWT tokens from CSV file
const jwtTokens = new SharedArray('JWT Tokens', function () {
    return open('./user_tokens.csv')
        .split('\n')
        .slice(1) // Skip the header row
        .map(token => token.trim())
        .filter(token => token.length > 0);
});

export let options = {
    vus: 1000, // 동시 사용자 수
    duration: '10s', // 테스트 수행 시간
};

function getRandomUniqueNumber(usedNumbers) {
    const availableNumbers = [1, 2, 3, 4].filter(num => !usedNumbers.includes(num));
    const randomIndex = Math.floor(Math.random() * availableNumbers.length);
    return availableNumbers[randomIndex];
}

export default function () {
    const url = '${SERVER_URL}';
    const token = jwtTokens[__VU % jwtTokens.length]; // Assign token based on virtual user ID
    const headers = {
        'Authorization': `${token}`,
        'Content-Type': 'application/json'
    };

    let usedNumbers = [];

    // 4번의 요청을 보내며, 각 요청에서 중복되지 않는 숫자를 선택합니다
    for (let i = 0; i < 4; i++) {
        const selectedNumber = getRandomUniqueNumber(usedNumbers);
        usedNumbers.push(selectedNumber); // 사용된 숫자를 기록합니다

        const payload = JSON.stringify({
            answer: selectedNumber.toString()
        });

        let res = http.post(url, payload, { headers: headers });

        // Validate response
        check(res, {
            'status is 200': (r) => r.status === 200
        }) || failureRate.add(1);

        sleep(0.5); // 0.5초 간격으로 카드 뒤집기
    }
}
  • k6을 활용한 Spike Test 역시 해당 요구사항을 반영하여 작성했으며, 테스트 결과와 실제 서비스가 이질감이 없도록 유저에게 발급된 JWT 토큰을 헤더에 실어 요청을 보내도록 했습니다.
  • 이제 작성한 k6.js와 미리 발급해둔 테스트용 JWT 토큰을 EC2 인스턴스에 옮기고 k6을 설치해준 후 "k6 run k6.js"로 테스트를 실행할 수 있습니다.
  • 동시 사용자 수를 조절하며 HTTP 요청 관련 데이터를 모니터링하였습니다.

주요 성능 지표 선정

  • k6의 실행 결과로부터 요청 처리 관련 지표(metric)를 선정했습니다.
image
  • http_req_blocked: 요청을 시작하기 전에 블락된(TCP 연결 슬롯을 기다리는) 시간
  • http_req_connecting: 서버에 TCP 연결을 설정하는 데 걸린 시간
  • http_req_duration: 요청에 걸린 총 시간 (보통 http_req_sending+http_req_waiting+http_req_receiving)
  • http_req_receiving: 서버로부터 응답 데이터를 받는 데 걸린 시간
  • http_req_sending: 서버에 데이터를 전송하는 데 걸린 시간
  • http_req_waiting: 서버의 응답을 기다리는 데 걸린 시간

단일 인스턴스

500명

Metric avg min med max p(90) p(95)
http_req_blocked 5.74ms 1.36µs 3.06µs 175.15ms 6.88µs 49.34ms
http_req_connecting 1.11ms 0s 0s 26.61ms 0s 14.65ms
http_req_duration 25.77ms 2.28ms 11.06ms 366.16ms 39.21ms 103.97ms
http_req_receiving 189.87µs 13.21µs 56.23µs 27.97ms 314.02µs 542.48µs
http_req_sending 560.47µs 6.5µs 20.79µs 34.12ms 1.13ms 2.95ms
http_req_waiting 25.02ms 2.19ms 10.68ms 360.41ms 36.81ms 100.43ms
iteration_duration 2.13s 2.01s 2.06s 2.61s 2.36s 2.43s

1000명

Metric avg min med max p(90) p(95)
http_req_blocked 12.45ms 1.24µs 3.15µs 440.81ms 11.04µs 79ms
http_req_connecting 9.95ms 0s 0s 303.04ms 0s 66.06ms
http_req_duration 144.1ms 2.55ms 121.32ms 674.35ms 283.89ms 352.33ms
http_req_receiving 425.58µs 13.18µs 52.54µs 107.85ms 378.1µs 828.43µs
http_req_sending 1.35ms 6.71µs 21.32µs 91.48ms 2.61ms 6.14ms
http_req_waiting 142.33ms 2.45ms 120.03ms 671.71ms 280.94ms 347.63ms
iteration_duration 2.63s 2.19s 2.59s 3.68s 2.99s 3.08s

1500명

Metric avg min med max p(90) p(95)
http_req_blocked 26.97ms 1.36µs 3.12µs 654.93ms 10.03µs 322.55ms
http_req_connecting 13.52ms 0s 0s 437.76ms 0s 125.15ms
http_req_duration 327.11ms 2.83ms 282.1ms 1.3s 560.3ms 657.73ms
http_req_receiving 495.8µs 14.1µs 49.71µs 163.97ms 355.66µs 845.63µs
http_req_sending 6.24ms 7.79µs 21.62µs 229.67ms 13.89ms 40.83ms
http_req_waiting 320.36ms 2.67ms 273.04ms 1.27s 552.93ms 652.76ms
iteration_duration 3.44s 2.67s 3.38s 4.77s 3.89s 4.02s

2000명

Metric avg min med max p(90) p(95)
http_req_blocked 28.11ms 1.29µs 3.11µs 786.17ms 10.7µs 220.24ms
http_req_connecting 4.13ms 0s 0s 86.75ms 0s 58.72ms
http_req_duration 484.02ms 3.84ms 455.58ms 1.62s 697.1ms 806.79ms
http_req_receiving 926.56µs 13.06µs 51.03µs 150.67ms 398.07µs 1.17ms
http_req_sending 2.75ms 6.87µs 22.3µs 203.26ms 5.1ms 15.87ms
http_req_waiting 480.34ms 3.72ms 451.4ms 1.62s 693.97ms 795.87ms
iteration_duration 4.06s 3.21s 3.96s 5.28s 4.7s 4.82s

2500명

Metric avg min med max p(90) p(95)
http_req_blocked 8.84ms 1.44µs 3.15µs 587.08ms 9.86µs 1.15ms
http_req_connecting 1.95ms 0s 0s 122.94ms 0s 1.07ms
http_req_duration 795.81ms 20.11ms 790.66ms 1.84s 1.01s 1.11s
http_req_receiving 620.6µs 12.14µs 53.86µs 137.65ms 379.77µs 865.75µs
http_req_sending 2.7ms 6.96µs 21.03µs 233.07ms 1.74ms 9.31ms
http_req_waiting 792.48ms 20.06ms 789.3ms 1.84s 1s 1.1s
iteration_duration 5.23s 4.01s 5.21s 6.39s 5.51s 5.76s

3000명

Metric avg min med max p(90) p(95)
http_req_blocked 44.59ms 1.42µs 3.11µs 1.49s 9.66µs 591.25ms
http_req_connecting 40.31ms 0s 0s 1.21s 0s 586.03ms
http_req_duration 777.91ms 2.87ms 793.78ms 1.85s 1.03s 1.2s
http_req_receiving 483.6µs 12.85µs 47.01µs 798.83ms 356.12µs 764.15µs
http_req_sending 14.66ms 6.72µs 20.53µs 743.73ms 19.36ms 95.83ms
http_req_waiting 762.76ms 2.57ms 777.02ms 1.68s 999.82ms 1.18s
iteration_duration 5.36s 3.8s 5.16s 1m0s 6.38s 6.99s

분산 환경

500명

Metric avg min med max p(90) p(95)
http_req_blocked 6.31ms 1.35µs 3.38µs 192.79ms 9µs 53.91ms
http_req_connecting 1.36ms 0s 0s 34.63ms 0s 16.99ms
http_req_duration 83.46ms 2.36ms 7.88ms 2.54s 82.73ms 565.11ms
http_req_receiving 421.55µs 13.61µs 178.45µs 50.79ms 623.17µs 1.1ms
http_req_sending 315.14µs 7.46µs 21.53µs 11.27ms 340.89µs 2.75ms
http_req_waiting 82.72ms 2.23ms 7.45ms 2.53s 81.32ms 562.15ms
iteration_duration 2.36s 2.01s 2.04s 4.7s 3.39s 3.8s

1000명

Metric avg min med max p(90) p(95)
http_req_blocked 11.32ms 1.33µs 2.88µs 354.9ms 9.89µs 82.44ms
http_req_connecting 2.1ms 0s 0s 46.96ms 0s 29.87ms
http_req_duration 50.22ms 2.07ms 26.49ms 328.82ms 137.01ms 176.03ms
http_req_receiving 417.15µs 12.75µs 44.5µs 112.63ms 374.07µs 858.47µs
http_req_sending 2.79ms 6.51µs 24.29µs 113.82ms 5.39ms 13.22ms
http_req_waiting 47.01ms 2.02ms 24.99ms 325.37ms 125.59ms 161.7ms
iteration_duration 2.26s 2.01s 2.18s 2.91s 2.58s 2.65s

1500명

Metric avg min med max p(90) p(95)
http_req_blocked 1.15ms 1.34µs 2.85µs 152.29ms 9.32µs 910.2µs
http_req_connecting 762.26µs 0s 0s 151.3ms 0s 832.89µs
http_req_duration 64.17ms 2.35ms 39.16ms 483.29ms 157.19ms 211.33ms
http_req_receiving 534.22µs 12.57µs 37.01µs 387.82ms 340.28µs 733.31µs
http_req_sending 5.03ms 6.72µs 24.46µs 347.71ms 8.41ms 21.71ms
http_req_waiting 58.6ms 2.29ms 36.96ms 387.45ms 145.45ms 185.42ms
iteration_duration 2.3s 2.01s 2.28s 2.91s 2.55s 2.61s

2000명

Metric avg min med max p(90) p(95)
http_req_blocked 31.04ms 1.24µs 2.86µs 945.27ms 10.23µs 279.43ms
http_req_connecting 5.07ms 0s 0s 120.13ms 0s 73.34ms
http_req_duration 133.57ms 2.49ms 88.17ms 636.6ms 349.09ms 425.82ms
http_req_receiving 677.8µs 11.98µs 30.87µs 398.54ms 330.59µs 665.53µs
http_req_sending 15.56ms 6.77µs 38.48µs 494.62ms 42.41ms 84.69ms
http_req_waiting 117.32ms 2.32ms 79.85ms 623.1ms 294.11ms 381.75ms
iteration_duration 2.76s 2.02s 2.62s 1m0s 3.47s 3.66s

2500명

Metric avg min med max p(90) p(95)
http_req_blocked 29.22ms 1.44µs 2.88µs 903ms 9.96µs 302.14ms
http_req_connecting 5.79ms 0s 0s 132.34ms 0s 76.99ms
http_req_duration 159.83ms 2.55ms 124.79ms 859.31ms 338.49ms 456.49ms
http_req_receiving 1.02ms 12.5µs 26.79µs 612.09ms 251.07µs 808.24µs
http_req_sending 23.13ms 6.89µs 163.83µs 794.46ms 55.52ms 88.78ms
http_req_waiting 135.67ms 2.44ms 112.13ms 677.87ms 279.72ms 375.95ms
iteration_duration 2.9s 2.01s 2.82s 4.39s 3.47s 3.65s

3000명

Metric avg min med max p(90) p(95)
http_req_blocked 44.38ms 1.18µs 2.71µs 1.18s 9.79µs 326.11ms
http_req_connecting 5.63ms 0s 0s 116.04ms 0s 80.67ms
http_req_duration 164.56ms 2.39ms 99.94ms 1.13s 445.08ms 650.01ms
http_req_receiving 913.42µs 12.05µs 26.85µs 949.44ms 280.59µs 871.96µs
http_req_sending 25.13ms 6.76µs 66.09µs 969ms 29.36ms 144.38ms
http_req_waiting 138.51ms 2.28ms 91.93ms 1.09s 361.92ms 549.32ms
iteration_duration 3.07s 2.02s 2.95s 5.01s 3.92s 4.11s

단일 인스턴스와 분산 환경 비교

  • 주요 지표를 통해 두 환경의 성능을 비교하였습니다.

http_req_duration (평균 요청 지연 시간)

image

  • 단일 인스턴스는 요청 수에 따라 지연 시간이 늘어나다가 특정 시점부터 60% 이상 폭등하는 경향을 보입니다.
  • 반면 분산 환경은 요청 수가 늘어나도 지연 시간이 뚜렷하게 증가하지는 않습니다.

http_req_sending (평균 요청 전송 시간)

image

  • 단일 인스턴스는 전송 시간이 불규칙한 측면을 보입니다.
  • 반면 분산 환경은 뚜렷한 전송 시간 증가가 나타났습니다. 이는 로드밸런싱 과정에서 추가적인 패킷 이동으로 인해 발생하는 추가적인 오버헤드를 원인으로 추론하였습니다.

http_req_waiting(평균 요청 대기 시간)

image

  • 단일 인스턴스는 요청 대기 시간이 뚜렷하게 증가함을 확인할 수 있습니다.
  • 반면 분산 환경은 요청 수가 늘어나도 거의 일정한 요청 대기 시간을 제공합니다.

TPS (초당 처리량)

image

  • 요청이 많아질수록 처리량이 서서히 벌어져, 동시 사용자수 3000명 기준 2배 가량의 차이가 나타났습니다.
  • 참고로 TPS는 (전체 HTTP 요청 수 / 테스트의 총 시간)으로 계산하였습니다.

결론

  • 예약 기반의 오토 스케일링을 통해 3개의 인스턴스를 추가로 활성화하고, 로드 밸런서를 통해 부하를 분산시키는 환경을 조성했습니다.
  • 그 결과 단일 인스턴스 대비 최고 부하 기준 2배 가량의 TPS 향상을 이뤄냈고, 평균 요청 지연/대기 시간을 거의 일정하게 유지할 수 있었습니다.
  • 그러나 4개의 인스턴스를 운영함에도 기대와 달리 TPS가 4배만큼 증가하지는 않는다는 점에서, 로드 밸런서를 통한 부하 분산 전략이 모든 문제를 해결하는 Silver-Bullet이 아니다라는 결론을 내릴 수 있었습니다.
  • 또한 각 노드의 CPU Usage를 조회한 결과 예상과 달리 부하를 정확하게 1/n 비율로 분산하지는 않는 것으로 확인되었습니다. 추가적인 조사가 필요합니다.
  • 평이한 성능의 서버를 Scale-Out함으로써 아주 짧은 시간 동안의 강한 부하로 인해 CPU가 100%를 초과하여 발생할 수 있는 서버 내 위험을 방지하여 QoS를 향상시킬 수 있었습니다.