부하테스트 시나리오 1: 회원가입 - 100-hours-a-week/2-hertz-wiki GitHub Wiki
1. 사용자 시나리오 설명
시나리오 설명: 사용자가 점진적으로 50명/분 속도로 회원가입 요청을 하는 흐름
행동 흐름:
- 사용자 정보 랜덤 생성 (성별, 나이대, MBTI, 종교, 흡연, 음주 상태 등)
/api/v1/users
엔드포인트에 POST 요청으로 회원가입 요청- 서버 응답 검증 및 성공/실패 집계
2. 테스트 개요
항목 | 내용 |
---|---|
테스트 목적 | 온보딩 저장 후 매칭을 위한 유저 임베딩 모델 처리 성능 검증 |
테스트 환경 | GCP e2-standard-2 (2vCPU / 8GB RAM), Ubuntu 22.04 |
대상 시스템 | AI (fastapi + chromadb) |
테스트 도구 | k6 |
테스트 일시 | 2025년 5월 12일 |
2.1 테스트 목적
회원가입을 하고 취향선택을 한 사용자의 회원가입을 처리하는 과정에서의 성능 및 부하를 검증하기 위함입니다.
2.2 테스트 API 및 호출 흐름
어플리케이션 | API 엔드포인트 | 메서드 | 설명 |
---|---|---|---|
FastAPI | /api/v1/users | POST | 사용자 가입 요청 (성별, 나이, 취향 등 포함) |
3. 테스트 조건 및 부하 프로파일
항목 | 값 |
---|---|
동시 사용자 수 | 3명 |
총 요청 수 | 정확히 30회 |
부하 생성 패턴 | shared-iterations (고정 개수 요청 배분) |
테스트 최대 지속 시간 | 2분 (여유 있는 실행 시간 설정) |
배치 처리 방식 | 멀티스레드 활용, 동시 요청 처리 |
데이터 다양성 | 랜덤 생성된 사용자 프로필 사용 |
4. 테스트 결과 요약 및 분석
응답 성능 지표
[유저0명]
항목 | 응답 시간 (ms) | 설명 |
---|---|---|
최소 응답 시간 (min) | 1214.54 | 가장 빠른 요청 응답 시간 |
평균 응답 시간 (avg) | 3927.26 | 전체 요청 평균 응답 시간 |
중앙값 응답 시간 (med) | 3910.22 | 전체 응답 시간의 중간값 (50% 지점) |
90% 백분위 (p90) | 4359.02 | 90% 요청이 이 시간 이내에 응답 |
95% 백분위 (p95) | 4564.58 | 95% 요청이 이 시간 이내에 응답 |
최대 응답 시간 (max) | 4678.88 | 가장 느린 요청 응답 시간 |
요청 완료 시간 | 40000.4 | 테스크 완료 시간 |
[유저 30명]
지표 항목 | 값 (ms) | 설명 |
---|---|---|
최소 응답 시간 (min) | 2037.33 ms | 가장 빠른 요청 응답 시간 |
평균 응답 시간 (avg) | 5569.19 ms | 전체 요청 평균 응답 시간 |
중앙값 응답 시간 (med) | 5739.24 ms | 전체 응답 시간의 중간값 (50% 지점) |
90% 백분위 응답 시간 (p90) | 6132.73 ms | 90% 요청이 이 시간 이내에 응답 |
95% 백분위 응답 시간 (p95) | 6184.10 ms | 95% 요청이 이 시간 이내에 응답 |
최대 응답 시간 (max) | 7318.09 ms | 가장 느린 요청 응답 시간 |
요청 완료 시간 | 57000.8ms | 테스크 완료 시간 |
시스템 리소스 사용 분석표
항목 | 수치 | 분석 요약 |
---|---|---|
CPU 사용량 (%CPU) | 약 55.78%→ user: 50.25%system: 5.53% | 부하 테스트 중 사용자 영역에서 CPU 점유율이 집중적으로 상승총 2 vCPU 기준으로는 약 111% 사용 → 1.1개 코어 과점유 |
CPU 유휴율 (id) | 약 42.7% (추정) | CPU 대부분이 FastAPI/PM2 프로세스에 사용되었으며 idle 감소 확인됨 |
메모리 사용량 (%MEM) | 약 2.02 GiB / 8 GiB (25.3%) | 전체 메모리 여유 충분→ 메모리 병목 없음 |
Load Average (1분) | 1.39 | 2 vCPU 기준으로 보면 부하 수준 양호 (Load Average < 2) |
Swap 사용량 | 0% 사용되지 않음 | 메모리가 충분하여 스왑 발생 없음 → 정상 |
5. 문제점 및 해결방안
- 전체적으로 요청이 많이 쌓일 수록, 유저가 많을수록 뒤에 들어온 요청의 처리 속도가 늦어진다. 반면에 사용하는 리소스의 양이 충분하여 유휴자원이 충분하다.
- AI 모델이 사용하는 리소스의 양을 늘리는 방향으로 코드를 수정해도 될 것 같다.
6. 코드 최적화 후 테스트 재실시
05.15 (개선 후)
[유저 0명]
지표 항목 | 값 (ms) | 설명 |
---|---|---|
최소 응답 시간 (min) | 935.94 ms | 가장 빠른 요청 응답 시간 |
평균 응답 시간 (avg) | 3005.58 ms | 전체 요청 평균 응답 시간 |
중앙값 응답 시간 (med) | 3141.64 ms | 전체 응답 시간의 중간값 (50% 지점) |
90% 백분위 응답 시간 (p90) | 3535.88 ms | 90% 요청이 이 시간 이내에 응답 |
95% 백분위 응답 시간 (p95) | 3577.70 ms | 95% 요청이 이 시간 이내에 응답 |
최대 응답 시간 (max) | 3634.59 ms | 가장 느린 요청 응답 시간 |
요청 완료 시간 | 31300 ms | 테스트 전체 소요 시간 (0m31.3s → 31.3초) |
[유저 30명]
지표 항목 | 값 (ms) | 설명 |
---|---|---|
최소 응답 시간 (min) | 1194.33 ms | 가장 빠른 요청 응답 시간 |
평균 응답 시간 (avg) | 3925.86 ms | 전체 요청 평균 응답 시간 |
중앙값 응답 시간 (med) | 4038.82 ms | 전체 응답 시간의 중간값 (50% 지점) |
90% 백분위 응답 시간 (p90) | 4399.33 ms | 90% 요청이 이 시간 이내에 응답 |
95% 백분위 응답 시간 (p95) | 4435.67 ms | 95% 요청이 이 시간 이내에 응답 |
최대 응답 시간 (max) | 4884.92 ms | 가장 느린 요청 응답 시간 |
요청 완료 시간 | 40800 ms | 테스트 전체 소요 시간 (0m40.8s → 40.8초) |
시스템 리소스 사용 분석표
항목 | 수치 | 분석 요약 |
---|---|---|
CPU 사용량 | 약 180% | |
(user 90.25% / system 1.5%) | 2 vCPU 기준으로 약 1.8코어 사용, FastAPI/PM2에서 주로 사용됨 | |
CPU 유휴율 (idle) | 약 20% (추정) | CPU 대부분이 애플리케이션 처리에 사용되었으며, 유휴 비율 감소 |
메모리 사용량 | 약 2.02 GiB / 8 GiB (25.3%) | 메모리 여유 충분, 병목 없음 |
Load Average (1분) | 1.2585 | 2 vCPU 기준에서 적절한 부하 수준 (2 미만이면 과부하 아님) |
Swap 사용량 | 0% 사용되지 않음 | 메모리 충분 → 스왑 미사용, 안정적인 상태 |
7. 결과
- 사용할 수 있는 CPU를 기준으로, 설정한 퍼센트만큼 모델이 사용하는 것을 확인하였고, 모델이 사용할 수 있는 CPU 사용량의 limit을 올려 개선하였다.
- 개선 전과 개선 후를 비교했을 때 확실한 속도 개선이 있었고, CPU 사용량이 두 배 증가하는 결과를 보였다.
- 메모리 사용 용량은 부하에 따른 변동이 미약한 것으로 확인되었다.
- 따라서 PROD 환경에서는 4vCPU/4GB + swap 메모리를 사용하여, 비용을 아낄 수도 있을 것 같다.
8. 부록
- 테스트 스크립트:
/home/devops/ai-signup-test.js
테스트 스크립트
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
// 사용자 정의 메트릭
const successCounter = new Counter('success_count');
const conflictCounter = new Counter('conflict_count');
const validationCounter = new Counter('validation_count');
const timeoutCounter = new Counter('timeout_count');
const totalRequestCounter = new Counter('total_requests');
const responseTimeTrend = new Trend('response_time_ms');
export let options = {
scenarios: {
signup_exact_30: {
executor: 'shared-iterations',
vus: 3, // 동시에 실행할 가상 사용자 수
iterations: 30, // ✅ 총 요청 수 = 30회
maxDuration: '2m', // 여유 있는 실행 시간
}
},
thresholds: {
'success_count': ['count>=45'],
'http_req_failed': ['rate<0.2'],
},
};
// 열거형 데이터
const AgeGroup = { AGE_20S: '20대', AGE_30S: '30대', AGE_40S: '40대', AGE_50S: '50대', AGE_60_PLUS: '60대 이상' };
const Gender = { MALE: '남자', FEMALE: '여자' };
const Religion = { NON_RELIGIOUS: '무교', CHRISTIANITY: '기독교', BUDDHISM: '불교', CATHOLICISM: '천주교', WON_BUDDHISM: '원불교', OTHER_RELIGION: '기타' };
const Smoking = { NO_SMOKING: '비흡연', SOMETIMES: '가끔 흡연', EVERYDAY: '매일 흡연', E_CIGARETTE: '전자담배', TRYING_TO_QUIT: '금연중' };
const Drinking = { NEVER: '전혀 안 마심', ONLY_IF_NEEDED: '필요할 때만 음주', SOMETIMES: '가끔 음주', OFTEN: '자주 음주', TRYING_TO_QUIT: '금주중' };
const MBTI = { ISTJ: 'ISTJ', ISFJ: 'ISFJ', INFJ: 'INFJ', INTJ: 'INTJ', ISTP: 'ISTP', ISFP: 'ISFP', INFP: 'INFP', INTP: 'INTP', ESTP: 'ESTP', ESFP: 'ESFP', ENFP: 'ENFP', ENTP: 'ENTP', ESTJ: 'ESTJ', ESFJ: 'ESFJ', ENFJ: 'ENFJ', ENTJ: 'ENTJ', UNKWON: 'MBTI 모름' };
function getKeysFromObject(obj) {
return Object.keys(obj);
}
function getRandomFromArray(array) {
return array[Math.floor(Math.random() * array.length)];
}
function getRandomSubArray(array, maxItems) {
const count = Math.floor(Math.random() * maxItems) + 1;
const shuffled = [...array].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
}
function generateRandomAiServerRequest(userIndex) {
const personalityOptions = ["CUTE", "RELIABLE", "SMILES_OFTEN", "DOESNT_SWEAR", "NICE_VOICE", "TALKATIVE", "GOOD_LISTENER", "ACTIVE", "QUIET", "PASSIONATE", "CALM", "WITTY", "POLITE", "SERIOUS", "UNIQUE", "FREE_SPIRITED", "METICULOUS", "SENSITIVE", "COOL", "SINCERE", "LOYAL", "OPEN_MINDED", "AFFECTIONATE", "CONSERVATIVE", "CONSIDERATE", "NEAT", "POSITIVE", "FRUGAL", "CHARACTERFUL", "HONEST", "PLAYFUL", "DILIGENT", "FAMILY_ORIENTED", "COMPETENT", "SELF_MANAGING", "RESPONSIVE", "WORKAHOLIC", "SOCIABLE", "LONER", "COMPETITIVE", "EMPATHETIC"];
const interestOptions = ["MOVIES", "NETFLIX", "VARIETY_SHOWS", "HOME_CAFE", "CHATTING", "DANCE", "SPACE_OUT", "COOKING", "BAKING", "DRAWING", "PLANT_PARENTING", "INSTRUMENT", "PHOTOGRAPHY", "FORTUNE_TELLING", "MAKEUP", "NAIL_ART", "INTERIOR", "CLEANING", "SCUBA_DIVING", "SKATEBOARDING", "SNEAKER_COLLECTION", "STOCKS", "CRYPTO"];
const foodOptions = ["TTEOKBOKKI", "MEXICAN", "CHINESE", "JAPANESE", "KOREAN", "VEGETARIAN", "MEAT_LOVER", "FRUIT", "WESTERN", "STREET_FOOD", "BAKERY", "HAMBURGER", "PIZZA", "BRUNCH", "ROOT_VEGETABLES", "CHICKEN", "VIETNAMESE", "SEAFOOD", "THAI", "SPICY_FOOD"];
const sportsOptions = ["BASEBALL", "SOCCER", "HIKING", "RUNNING", "GOLF", "GYM", "PILATES", "HOME_TRAINING", "CLIMBING", "CYCLING", "BOWLING", "BILLIARDS", "YOGA", "TENNIS", "SQUASH", "BADMINTON", "BASKETBALL", "SURFING", "CROSSFIT", "VOLLEYBALL", "PINGPONG", "FUTSAL", "FISHING", "SKI", "BOXING", "SNOWBOARD", "SHOOTING", "JIUJITSU", "SWIMMING", "MARATHON"];
const petOptions = ["DOG", "CAT", "REPTILE", "AMPHIBIAN", "BIRD", "FISH", "LIKE_BUT_NOT_HAVE", "HAMSTER", "RABBIT", "NONE", "WANT_TO_HAVE"];
const selfDevOptions = ["READING", "STUDYING", "CAFE_STUDY", "LICENSE_STUDY", "LANGUAGE_LEARNING", "INVESTING", "MIRACLE_MORNING", "CAREER_DEVELOPMENT", "DIET", "MINDFULNESS", "LIFE_OPTIMIZATION", "WRITING"];
const hobbyOptions = ["GAMING", "MUSIC", "OUTDOOR", "MOVIES", "DRAMA", "CHATTING", "SPACE_OUT", "APPRECIATION", "DANCE", "COOKING", "BAKING", "DRAWING", "PLANT_CARE", "INSTRUMENT", "PHOTOGRAPHY", "WEBTOON", "MAKEUP", "INTERIOR", "CLEANING", "SCUBA_DIVING", "COLLECTING", "STOCKS"];
return {
userId: userIndex,
emailDomain: `test${userIndex}@kakaotech.com`,
gender: getRandomFromArray(getKeysFromObject(Gender)),
ageGroup: getRandomFromArray(getKeysFromObject(AgeGroup)),
MBTI: getRandomFromArray(getKeysFromObject(MBTI)),
religion: getRandomFromArray(getKeysFromObject(Religion)),
smoking: getRandomFromArray(getKeysFromObject(Smoking)),
drinking: getRandomFromArray(getKeysFromObject(Drinking)),
personality: getRandomSubArray(personalityOptions, 3),
preferredPeople: getRandomSubArray(personalityOptions, 3),
currentInterests: getRandomSubArray(interestOptions, 3),
favoriteFoods: getRandomSubArray(foodOptions, 3),
likedSports: getRandomSubArray(sportsOptions, 3),
pets: getRandomSubArray(petOptions, 3),
selfDevelopment: getRandomSubArray(selfDevOptions, 3),
hobbies: getRandomSubArray(hobbyOptions, 2)
};
}
function generateRequestData() {
const userIndex = Math.floor(Math.random() * 10000); // 랜덤 유저 ID 생성
return generateRandomAiServerRequest(userIndex);
}
export default function () {
const requestData = generateRequestData();
const requestBody = JSON.stringify(requestData);
const response = http.post(
'<http://34.80.196.19:8000/api/v1/users>',
requestBody,
{ headers: { 'Content-Type': 'application/json' }, timeout: '20s' }
);
responseTimeTrend.add(response.timings.duration);
totalRequestCounter.add(1);
check(response, {
'status is 200 or 201': (r) => r.status === 200 || r.status === 201,
'status is 409 (conflict)': (r) => r.status === 409,
'status is 422 (validation error)': (r) => r.status === 422,
'response has valid format': (r) => {
try {
JSON.parse(r.body);
return true;
} catch (_) {
return false;
}
}
});
try {
const parsed = JSON.parse(response.body);
if (response.status === 200 || response.status === 201) successCounter.add(1);
else if (response.status === 409) conflictCounter.add(1);
else if (response.status === 422) validationCounter.add(1);
else timeoutCounter.add(1);
} catch (_) {
timeoutCounter.add(1);
}
}
export function handleSummary(data) {
const safe = (name) => data.metrics[name]?.values ?? {};
return {
stdout: JSON.stringify({
summary: {
vus: 'dynamic ramping up to 50 arrivals/minute',
totalRequests: safe('total_requests').count ?? 0,
success: safe('success_count').count ?? 0,
conflicts: safe('conflict_count').count ?? 0,
validationErrors: safe('validation_count').count ?? 0,
timeouts: safe('timeout_count').count ?? 0,
response: {
min: (safe('response_time_ms').min ?? 0).toFixed(2),
avg: (safe('response_time_ms').avg ?? 0).toFixed(2),
med: (safe('response_time_ms').med ?? 0).toFixed(2),
p90: (safe('response_time_ms')['p(90)'] ?? 0).toFixed(2),
p95: (safe('response_time_ms')['p(95)'] ?? 0).toFixed(2),
max: (safe('response_time_ms').max ?? 0).toFixed(2),
}
}
}, null, 2)
};
}