부하테스트 시나리오 1: 회원가입 - 100-hours-a-week/2-hertz-wiki GitHub Wiki

1. 사용자 시나리오 설명

시나리오 설명: 사용자가 점진적으로 50명/분 속도로 회원가입 요청을 하는 흐름

행동 흐름:

  1. 사용자 정보 랜덤 생성 (성별, 나이대, MBTI, 종교, 흡연, 음주 상태 등)
  2. /api/v1/users 엔드포인트에 POST 요청으로 회원가입 요청
  3. 서버 응답 검증 및 성공/실패 집계

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초)

5

시스템 리소스 사용 분석표

항목 수치 분석 요약
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)
    };
  }