항해 과제로 구축한 [콘서트 예약 서비스]에 대해 장애를 예방하고 성능을 확인하기 위해 부하 테스트를 해보려고 한다.
부하 테스트 툴은 k6를 사용하고, 로컬 환경에서 테스트를 진행한다.
부하 테스트의 목적은 서버가 얼마만큼의 요청을 견딜 수 있는지, 그 과정에서 어떤 장애가 발생할 수 있는지도 예측하고 파악해보는 것이다.
1. 테스트를 계획하고
2. 시나리오를 수립하고
3. 실제로 테스트를 돌려본 후
4. 장애를 예상하여 개선해나가는 과정으로 진행해보았다.
우선, 계획하기에 앞서 테스트의 종류를 간단히 살펴보면
- Smoke Test
- 최소한의 부하로 구성된 테스트로, 테스트 시나리오에 오류가 없는지, 응답 시간 등을 확인할 수 있다.
- VUser: 1 - 2

- Load Test
- 서비스가 얼만큼의 부하를 견뎌낼 수 있는지에 대한 테스트
- VUser: 평균 - 최대 VUser

- Stress Test
- 점진적으로 부하를 증가시켜 한계를 시험해보는 테스트

- Spike Test
- 즉각적인 최대 부하를 주어 시스템을 테스트하는 방법

각각의 테스트 대상의 서비스 목적에 적합한 테스트 방식을 선정할 수 있다.
0. 요구사항
- 테스트 전제조건 정리
- 대상 시스템 범위
- 목푯값 설정 (latency, throughput, target RPS)
- 시나리오 및 스크립트 작성
- 접속 트래픽 부하가 높은 API
- 서비스의 주 기능 목적에 부합하는 API
- 테스트 진행
- 각 시나리오의 특성에 맞게 smoke test, load test, stress test, spike test 를 진행
- 장애 예측 및 개선
- 테스트 결과를 분석하여 향후 장애 발생 가능성을 예측해보고, 장애를 가상하여 대응 및 개선
1. 테스트 계획
대상 시스템 범위
- Application, DB, Redis, kafka
목푯값 설정
- 콘서트 예약 서비스 특성에 따라 1일 이용자 수(DAU)보다는 단순히 최대 트래픽 중심으로 계산하였고, 상용 서비스가 아니기 때문에 우선 타이트하게 설정해보았다.
- 목표 rps 정하기 (1초당 요청 수)
- 최고 동시 트래픽이 10만명 정도는 몰린다고 가정
- 그러나 서버의 한계로 대기열 진입 api의 목표 rps는 1000
- 대기열 시스템으로 1000명을 동시 진입 가능하게 하며, 10초(콘서트 오픈런 완료 시간을 10초로 가정)당 200명의 대기 유저를 활성화하며 성능을 유지하는 것을 목표
- 예약 시스템의 목표 rps는 840...?
- 10초 동안 콘서트 목록 조회(1), 콘서트 회차 목록 조회(1-2), 예약 가능 좌석 목록 조회(2-3), 예약 요청(1) 이 이루어진다고 가정하여 총 요청 수는 7회로 가정
- 최대 동접 수 1200명으로 가정하여 1200 * 7 / 10 = 840
- 예약 시스템의 목표 rps는 840...?
- 최고 동시 트래픽이 10만명 정도는 몰린다고 가정
- 응답 시간
- p95: 2-300ms 이내
- p99: 500ms 이내 목표
- VUser (가상 사용자 수)
- VUser = (rps * 한번의 시나리오를 완료하는데 걸리는 시간(요청-응답 시간 + 지연 시간)) / (시나리오 당 요청수)
- 대기열 API
- 로컬 서버의 한계가 있으니 대기/활성이 잘 되는지 최대로 해보기
- 예약 시나리오
- (840 * 10s) / 7 로 할 수 있지만, 하나의 k6 서버가 만들어낼 수 있는 부하에 한계가 있으므로 지연 시간을 최대한 짧게 계산하여 가상 사용자 수를 줄일 수 있도록 계산
- 하나의 요청-응답 시간을 200ms 목표로 잡고, (840 * (0.2s * 7 + 3s)) / 7 = 528명
2. 시나리오 수립
시나리오 계획
우선 내 콘서트 예약 서비스에 작성된 API 기능 중에서 병목 현상이나 많은 트래픽을 야기할 만한 기능은
[대기열 정보 확인 및 진입], [예약 가능한 좌석 조회], [콘서트 예약]이며, [유저 잔액 충전 및 사용]도 예약 시에 많은 트래픽을 받을 수 있다.
* 시나리오 전제 조건 : 콘서트 1개, 콘서트 회차 3개, 회차 당 좌석 1000개, 현재 생성되어 있는 예약 98개
우선 대기열 작동에 따라 일정한 수의 유저가 진입하여 그 때부터 DB I/O 부하를 주기 때문에
부하테스트를 진행해 볼 시나리오는 크게 2가지로 나누어 세워보았다.
1. 대기열 확인 > 대기 or 진입
(현재 대기열 관리 방식은 시간마다 추가로 n명씩 활성화시키는 방식)
2. 예약 가능한 좌석 조회 > 콘서트 예약 > 결제 요청 > 예약 완료
여기서
공통적으로는 Smoke Test, Load Test,
각 시나리오의 특성에 따라
1번 시나리오는 Spike Test,
2번 시나리오는 Stress Test 를 추가로 진행하려고 한다.
3. 부하테스트 실행
1. 대기열 토큰 발급
- Smoke Test
// waiting-smoke.js
import http from 'k6/http';
import {check, fail} from 'k6';
export let options = {
vus: 1, // 1 user looping for 1 minute
duration: '10s',
thresholds: {
http_req_duration: ['p(99)<800'], // 요청의 99%가 800ms 미만으로 완료되어야 함
},
};
export default function () {
// 대기열 요청
const waitingRes = http.get(`http://localhost:3000/waits/token?userId=1`);
check(waitingRes, {
'waiting check': res => res.status === 200
});
if (waitingRes.status !== 200) {
fail('Failed check for waiting-page');
}
return waitingRes;
}

- Load Test
가상 사용자 수를 55초에 걸쳐 10000명으로 늘리니까 4000개의 요청을 못 하고 끝남
60초 동안 서서히 늘려 1000명을 유지하는 값을 설정하여 수행해보았다. 결과는 3개의 요청이 실패
import http from 'k6/http';
import { check, fail } from 'k6';
export let options = {
stages: [
{ duration: '5s', target: 200 },
{ duration: '5s', target: 400 },
{ duration: '5s', target: 600 },
{ duration: '5s', target: 800 },
{ duration: '5s', target: 1000 },
{ duration: '10s', target: 1000 },
{ duration: '10s', target: 1000 },
{ duration: '10s', target: 1000 },
{ duration: '5s', target: 0 }
],
thresholds: {
http_req_duration: ['p(99)<500'], // 요청의 99%가 500ms 미만으로 완료되어야 함
},
};
export default function () {
// 각 VU에 대해 고유한 userId 생성 (1부터 시작)
let userId = __VU;
// 대기열 요청 보내기
const waitingRes = http.get(`http://localhost:3000/waits/token?userId=${userId}`);
check(waitingRes, {
'waiting check': res => res.status === 200,
});
if (waitingRes.status !== 200) {
fail(`Failed check for waiting-page with userId=${userId}`);
}
}


- Spike Test
5초 동안 5000명 요청을 해보았다.


2. 예약 가능한 좌석 조회 > 콘서트 예약 > 결제 요청 > 예약 완료
- Smoke Test
import http from 'k6/http';
import {check, fail, sleep, group} from 'k6';
export let options = {
vus: 1, // 1 user looping for 1 minute
duration: '10s',
thresholds: {
http_req_duration: ['p(99)<500'], // 요청의 99%가 500ms 미만으로 완료되어야 함
},
};
export default function () {
let selectedSeatNum;
let userId = __VU;
let paymentId;
let nextPaymentId= 1;
let nextReservationId = 1;
group('Step 1: get available seats', function () {
let seatsRes = http.get(`http://localhost:3000/dates/1/seats`);
check(seatsRes, {
'Seats available status is 200': (res) => res.status === 200,
});
if (seatsRes.status !== 200) {
fail('Failed to fetch available seats');
}
// 1부터 1000 사이의 랜덤 좌석 번호 추출
function getRandomSeatNumber() {
return Math.floor(Math.random() * 1000) + 1;
}
// 랜덤 좌석 선택
selectedSeatNum = getRandomSeatNumber();
});
sleep(1);
group('Step 2: reserve', function () {
let reservePayload = JSON.stringify({
concertId: 1,
concertDateId: 1,
userId: userId,
seatNum: selectedSeatNum
});
let reserveParams = {
headers: {
'Content-Type': 'application/json',
},
};
let reserveRes = http.post(`http://localhost:3000/reservations`, reservePayload, reserveParams);
check(reserveRes, {
'Reservation status is 200': (res) => res.status === 200,
});
});
group('Step 3: create payment', function () {
let paymentPayload = JSON.stringify({
reservationId: nextReservationId,
price: 79000,
});
nextReservationId++;
let paymentParams = {
headers: {
'Content-Type': 'application/json',
},
};
let paymentRes = http.post('http://localhost:3000/payments', paymentPayload, paymentParams);
check(paymentRes, {
'Payment creation status is 200': (res) => res.status === 200,
});
});
sleep(1);
group('Step 4: Payment request', function () {
let paymentRequestPayload = JSON.stringify({
paymentId: nextPaymentId
});
nextPaymentId++;
let paymentRequestParams = {
headers: {
'Content-Type': 'application/json',
},
};
let paymentRequestRes = http.post('http://localhost:3000/payments/${paymentId}', paymentRequestPayload, paymentRequestParams);
check(paymentRequestRes, {
'Payment request status is 200': (res) => res.status === 200,
});
if (paymentRequestRes.status !== 200) {
fail(`Failed to process payment with userId=${userId} and paymentId=${paymentId}`);
}
});
// 대기 시간 추가 (선택 사항)
sleep(1);
}


- Load Test
최대 300건의 요청을 생성하였다.


- Stress Test
5초마다 200명씩 늘려가면서 2000건의 요청을 보냈다.


예약 서비스 로직을 부하 테스트를 돌리면서, Load Test 때와 Stress Test 때의 CPU와 메모리 사용량을 봤는데, 두 가지가 비슷한 양상을 보였다.


로컬 환경에서의 한계가 있어 테스트가 더 이상 유의미할 지에 대해서 잘 모르겠다..
일단 로컬 환경에서 최대치의 활용을 하면서는, 서비스가 다운되지 않고 잘 실행되는 것을 확인하였다.
예약 서비스도 목표했던 동시 처리량을 잘 소화했다고 생각하였다.
4. 장애 예상과 대응
P99 를 500ms 로 잡아놓고 테스트하였는데, k6 부하테스트를 로컬에서 돌려본 결과로는 목표 VUser 값 내에서는 다 통과하였다.
솔직히 그냥 부하테스트가 돌아가게끔만 테스트해본거 같아서 유의미한 데이터인지는 잘 모르겠다.
대기열 로직의 경우 nGrinder의 pinpoint 처럼 요청이 어느 위치로 흡수되는지를 보면 더 좋을 것 같은데 시간 관계상 그렇게 진행을 못 해봐서 아쉽다. 후에 혼자 해봐야겠다.
하지만 아직은 눈에 띄는 처리량 다운 요소나 슬로우 트랜잭션 요소가 없었다고 생각된다.
현재 서비스에서는 대기열 토큰 처리로 예약 서비스에 요청을 보낼 수 있는 사용자 수를 컨트롤하기 때문에,
대기열 확인을 폴링 방식으로 계속 쏠 때, 또는 진입 페이지에 10만명 이렇게 많은 사용자가 몰릴 때 장애가 일어날 가능성이 가장 클 것이다. (혹은 레디스 적재량을 넘어설 수도 있겠지?)
이럴 경우에는 레디스를 일정 기준에 따라 스케일 아웃 처리하면 될 것 같은데, 지금 수준에서는 구현해보기가 어려운 것 같다.
마치며
api 쿼리들을 테스트해본 결과, 목표치에서 벗어나는 속도의 장애 요소는 없었던 것 같다.
하지만 부하테스트를 직접 해보며 한정된 자원에서 테스트해본 것 같아서 아쉽고, 네트워크에 대해서 내가 아는 부분이 거의 없어서 테스트 결과에 대해서 이해가 많이 부족한 것, 변수를 설정하고 움직여보는 재량이 많이 부족함을 느꼈다.
최대한 테스트를 진행해보고 고민해보았는데, 제일 어려웠던 과제였다. 다음에 다시 학습해서 해봐야지..
'캠프 > 항해 플러스 4기' 카테고리의 다른 글
| 10주 간의 항해 끝! [항해 플러스 백엔드 4기] - 비전공자 첫 부트캠프 상세한 후기 (1) | 2024.05.28 |
|---|---|
| 장애 대응 - 부하 테스트 준비(nGrinder → k6) (1) | 2024.05.21 |
| [9주차] WIL (0) | 2024.05.18 |
| 콘서트 좌석 예약 정보를 데이터 플랫폼으로 전달한다면? (2) | 2024.05.16 |
| 콘서트 예약 서비스의 Transaction 범위와 책임 분리 방안 설계 (0) | 2024.05.16 |