소프트웨어는 다양한 상황에서 장애가 발생할 수 있습니다. 이러한 장애에 효과적으로 대응하기 위해서는 다음 세 가지 원칙을 반드시 기억하고 실천해야 합니다:
- 예방 가능한 장애를 방지할 것
사전에 발생할 수 있는 문제를 예방하는 것이 최우선입니다. - 장애 발생 시 신속하게 전파하고 해결할 것
문제가 발생했다면 즉시 공유하고 빠르게 해결해야 합니다. - 재발 방지 대책을 마련할 것
동일한 장애가 반복되지 않도록 원인을 분석하고 조치를 취해야 합니다.
장애 발생에 대비하기 위해서는 사전 테스트를 통해 발생 가능한 상황을 시뮬레이션할 필요가 있습니다. 이를 위해 부하 테스트를 활용할 수 있습니다.
부하 테스트는 소프트웨어가 특정 시나리오에서 안정적으로 동작하는지를 검증하는 과정으로, 주요 목적은 다음과 같습니다:
- 예상 TPS(Transaction Per Second) 검증
예상되는 초당 트랜잭션 처리량을 시스템이 제대로 처리할 수 있는지 확인합니다. - 응답 시간 검증
평균, 중앙값, 최대 응답 시간을 측정하여 성능을 평가합니다. - 동시성 이슈 검증
다량의 트래픽이 유입될 때 동시 처리에 문제가 없는지 확인합니다.
부하 테스트는 테스트 시간과 트래픽 양에 따라 다음 네 가지 유형으로 구분됩니다:
- Load Test (부하 테스트)
- 시스템이 예상 부하를 정상적으로 처리할 수 있는지 평가합니다.
- 특정 부하를 일정 시간 동안 가해 이상 여부를 파악합니다.
- 목표 부하를 설정하고, 이에 맞는 애플리케이션 배포 스펙을 고려할 수 있습니다.
- Endurance Test (내구성 테스트)
- 시스템이 장기간 안정적으로 운영될 수 있는지 평가합니다.
- 장기간 부하가 가해졌을 때 발생할 수 있는 문제를 파악합니다.
- 메모리 누수, 느린 쿼리 등 장기 운영 시 나타날 수 있는 숨겨진 문제를 발견합니다.
- Stress Test (스트레스 테스트)
- 시스템이 증가하는 부하를 얼마나 잘 처리할 수 있는지 평가합니다.
- 부하가 점진적으로 증가할 때 발생하는 문제를 파악합니다.
- 장기적인 운영 계획과 확장성을 검토할 수 있습니다.
- Peak Test (최고 부하 테스트)
- 일시적으로 많은 부하가 가해졌을 때 시스템이 잘 처리하는지 평가합니다.
- 임계 부하를 순간적으로 제공했을 때 정상적으로 동작하는지 파악합니다.
- 선착순 이벤트 등에서 정상적인 서비스 제공 가능 여부를 평가할 수 있습니다.
이번 부하 테스트에서는 일반적으로 많이 사용하는 Load Test와 Peak Test를 통해 시스템의 성능을 검증해 보도록 하겠습니다.
부하 테스트
시나리오 작성
부하 테스트를 진행하기 전 우선 서비스 특성에 맞춰 테스트를 진행할 상황을 먼저 생각해보아야 합니다.
- 트래픽이 많이 몰릴 것인가?
- 서비스 입장 토큰을 사용하지 않고 호출하는 API인가?
위 내용들로 생각을 해보면 현재 서비스에서 제공하는 시나리오들을 확인을 해보겠습니다.
1. 대기열 참여 및 토큰 조회
대기열은 콘서트를 임시예약 및 결제를 하기 위해서는 필수적으로 거쳐야 하는 API입니다.
입장인원을 2000명으로 통제하고 있지만 대기열 진입하는 요청은 무제한으로 들어올 수 있기 때문에 대기열 참여, 대기순위 및 토큰 조회 API는 부하 테스트를 진행해야 한다 생각하였습니다.
Waitng Scenario Script ( Load Test )
import http from 'k6/http';
import {check, sleep} from 'k6';
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
export let options = {
scenarios: {
waiting_scenario: {
executor: 'ramping-vus',
startVUs: 100,
stages: [
{ duration: '10s', target: 200 },
{ duration: '10s', target: 300 },
{ duration: '10s', target: 400 },
{ duration: '10s', target: 500 },
{ duration: '10s', target: 0 }
],
exec: 'waiting_scenario'
}
}
};
export function waiting_scenario() {
let userId = randomIntBetween(1, 100000);
let seriesId = randomIntBetween(1, 10000);
waiting(userId, seriesId);
sleep(1);
waitingCheck(userId, seriesId);
}
function waiting(userId, seriesId) {
let res = http.post(
'http://localhost:8080/waiting-queue/join',
JSON.stringify({ userId, seriesId }),
{
headers: {'Content-Type': 'application/json'},
}
)
check(res, {
'status was 200': (r) => r.status === 200
})
}
function waitingCheck(userId, seriesId) {
let res = http.get('http://localhost:8080/waiting-queue/waiting-count?userId=' + userId +'&seriesId=' + seriesId);
check(res, {
'status was 200': (r) => r.status === 200
})
}
성능 측정 ( Load Test )
위 성능 측정 표를 확인해 보면 1건의 실패 케이스가 나온 것을 확인할 수 있습니다.
하지만 이는 성능 문제가 아닌 UserId와 SeriesId로 대기열에 입장하는데 해당 아이디들이 중복으로 들어갈 경우 실패하는 것이라 올바른 상황으로 볼 수 있습니다.
아래 stage로 Peak Test를 진행해 보았습니다.
극단적으로 5000 Target으로 올렸지만 성능을 확인해 보았을 때 정상적으로 처리하고 있는 것으로 보았습니다.
export let options = {
scenarios: {
waiting_scenario: {
executor: 'ramping-vus',
startVUs: 100,
stages: [
{ duration: '10s', target: 2000 },
{ duration: '10s', target: 3000 },
{ duration: '10s', target: 4000 },
{ duration: '10s', target: 5000 },
{ duration: '10s', target: 0 }
],
exec: 'waiting_scenario'
}
}
};
성능 측정( Peak Test )
2. 콘서트 결제
콘서트 결제의 경우 앞에서 했던 대기열 입장 및 서비스 입장, 토큰 발급, 콘서트 시리즈 및 좌석 조회, 포인트 충전, 임시예약, 결제 순으로 시나리오가 이루어집니다.
Concert Payment Scenario Script ( Load Test )
import http from 'k6/http';
import {check, sleep} from 'k6';
import {randomIntBetween, randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
let concertId = '0006740c-54d6-11ef-8c39-0242ac110002'
export let options = {
scenarios: {
payment_scenario: {
executor: 'ramping-vus',
startVUs: 100,
stages: [
{ duration: '10s', target: 200 },
{ duration: '10s', target: 300 },
{ duration: '10s', target: 400 },
{ duration: '10s', target: 500 }
],
exec: 'payment_scenario'
}
}
};
export function payment_scenario() {
let userId = randomIntBetween(1, 100000);
let seriesId = getSeriesId();
chargePoint(userId);
waiting(userId, seriesId);
sleep(1);
let tokenId = getTokenId(userId, seriesId);
let seat = getSeat(tokenId, seriesId)
let temporaryReservationId = temporaryReservation(tokenId, userId, seriesId, seat.seatId)
}
// ConcertSeries조회
function getSeriesId() {
let res = http.get('http://localhost:8080/concert/series/' + concertId);
check(res, {
'status was 200': (r) => r.status === 200
})
let concertSeriesList = res.json().seriesList;
return concertSeriesList[0].seriesId;
}
// ConcertSeries조회
function getSeat(tokenId, seriesId) {
let res = http.get('http://localhost:8080/concert/seat/' + seriesId, {
headers: {
tokenId
}
});
check(res, {
'status was 200': (r) => r.status === 200
})
let concertSeatList = res.json().seatList;
return randomItem(concertSeatList);
}
//포인트 충전
function chargePoint(userId) {
let res = http.patch(
'http://localhost:8080/point/charge',
JSON.stringify({ userId, amount: 10000 }),
{
headers: {'Content-Type': 'application/json'},
}
)
check(res, {
'status was 200': (r) => r.status === 200
})
}
// 대기열 진입
function waiting(userId, seriesId) {
let res = http.post(
'http://localhost:8080/waiting-queue/join',
JSON.stringify({ userId, seriesId }),
{
headers: {'Content-Type': 'application/json'},
}
)
check(res, {
'status was 200': (r) => r.status === 200
})
}
// 토큰아이디 조회
function getTokenId(userId, seriesId) {
let tokenId = '';
while(tokenId === '') {
let res = http.get('http://localhost:8080/waiting-queue/waiting-count?userId=' + userId +'&seriesId=' + seriesId);
check(res, {
'status was 200': (r) => r.status === 200
})
let waitingData = res.json();
tokenId = waitingData.tokenId;
if(tokenId === '') sleep(1)
}
return tokenId;
}
// 임시예약
function temporaryReservation(tokenId, userId, seriesId, seatId) {
let res = http.post(
'http://localhost:8080/temporary-reservation',
JSON.stringify({
userId,
concertId,
seriesId,
seatId
}),
{
headers: {
'Content-Type': 'application/json',
tokenId
},
}
)
check(res, {
'status was 200': (r) => r.status === 200
})
if(res.status === 200) {
temporaryReservationPayment(tokenId, userId, temporaryReservationId);
}
return res.body;
}
// 임시예약 결제
function temporaryReservationPayment(tokenId, userId, temporaryReservationId) {
let res = http.post(
'http://localhost:8080/payment',
JSON.stringify({
userId,
temporaryReservationId
}),
{
headers: {
'Content-Type': 'application/json',
tokenId
},
}
)
check(res, {
'status was 200': (r) => r.status === 200
})
return res.body;
}
성능 측정( Load Test )
테스트 결과를 보면 14%의 실패 케이스가 존재합니다.
이때 테스트 중 발생할 수 있는 케이스는 아래와 같습니다.
- seat가 이미 결제된 경우 - 동시성 처리로 인해 발생하는 오류 정상케이스
- 임시예약이 이미 결제된 경우 - 중복 결제처리 방지로 인해 발생하는 오류 정상케이스
이리하여 제공 중인 API들의 성능에는 문제가 없다 생각합니다.
Peak Test를 진행하였을 때는 아래와 같습니다.
성능 측정( Peak Test )
2000, 3000, 4000, 5000이 각 10s동안 요청이 가도록 설정하였고, 성능적으로 생각보다 느린 것을 확인할 수 있습니다.
다만 이번에도 실패 케이스는 위에서 말한 동시성 처리로 인한 의도적 오류 발생이므로 문제는 없다 생각합니다.
시나리오를 설정하고 테스트를 진행해 보니 따로 문제가 발생하는 점은 발견하지 못하였습니다.
다만, 테스트 중 성느억 문제가 있다 판단되는 API들이 존재하여 이를 개선해보고자 합니다.
문제 되는 API 개선 사항
Concert
1. Concert 목록 조회
개인적으로 가장 많은 TPS가 몰리는 API라 생각합니다.
그 이유는 대기열에 입장하지 않더라도 조회 가능한 API이고, 해당 서비스에서 가장 먼저 조회되는 데이터이기 때문입니다.
즉, 모든 유저들은 해당 서비스를 사용하려면 해당 API를 호출해야 하는 상황인 것입니다.
그러므로 TPS 100 이상이 나와주는 것이 적절한 성능이라 생각합니다.
기존 로직으로 조회했을 때의 성능입니다.
현재 성능으로 보았을 때 TPS는 1m 30s동안 1550건의 트래픽을 처리하였으므로 1550/90 = 17.2... 약 17 TPS의 성능을 가지고 있습니다.
이는 매우 심각한 상황으로 개선의 필요성이 많이 있다고 볼 수 있습니다.
여기서 성능을 개선하는 방향으로 Paging을 적용해 보았습니다.
Paiging을 적용 후 TPS를 측정해 보았을 때 1m 동안 5900건의 트래픽을 처리하였으므로 5900/60 = 98.3... 약 98 TPS의 성능이 나왔습니다. 즉, 5배 이상 되는 성능이 향상된 것을 확인해 볼 수 있었습니다.
2. ConcertSeries 목록 조회
콘서트 시리즈목록 조회의 경우 concertId로 조회가 되므로 페이징과 캐시를 제거하였음에 더 아래와 같이 5657/90 = 62.85... 로써
약 63 TPS의 성능을 제공할 수 있습니다.
3. ConcertSeat 목록 조회
ConcertSeat목록의 경우 ConcertSeriesId에 의해 적은 양의 데이터가 조회되고, Seat조회부터는 토큰을 가지고 접근하는 API이기 때문에 부하 테스트를 실행하지 않아도 된다 판단하였습니다.
이와 같이 서버의 장애를 대응하는 것은 매우 중요한 사항이라 볼 수 있습니다.
현재 상황의 경우 특정 API의 조회 성능 문제가 있었는데, 조회가 느린 게 왜 장애인가?? 할 수 있지만 서비스 자체의 성능을 저하시키는 요인으로 페이징 처리를 하지 않아 데이터가 많아질수록 성능 저하가 발행하는 엄연히 장애라고 판단할 수 있습니다.
Repository
https://github.com/KrongDev/hhplus-concert
항해를 마무리하면서 최종 발표자료
https://docs.google.com/presentation/d/14Tpj91PwHInYeVTrdihKlyE1dNZWB2djiMpayMHz7aY/edit?usp=sharing
'Server' 카테고리의 다른 글
WebServer와 WebApplicationServer가 뭔가요? (2) | 2024.10.23 |
---|---|
[VM] MAC OS에서 VM으로 Ubuntu 설치 및 실행하기 (3) | 2024.10.13 |
[Kafka] Kafka란?( 작성중... ) (0) | 2024.08.11 |
[Event] @TransactionalEventListener 데이터가 저장 안 돼요... (0) | 2024.08.11 |
[EDA] 기존 서비스를 분석하고 Event를 활용하여 Transaction범위 분리 작업 (0) | 2024.08.07 |