K6로 테스트 해본 예약 동시성 성능 테스트 - fitpassTeam/fitpass GitHub Wiki

K6로 테스트 해본 예약 동시성 성능 테스트

배경 : 예약 동시성 제어 K6 + Grafana 테스트

개요

헬스장 PT 예약 시스템을 개발하면서 동시성제어 부분에 성능테스트를 확실히 해야겠다고 생각이 들었다. "여러 회원이 동시에 같은 시간대에 한 트레이너에게 예약하면 어떻게 처리가 되지?" 라는 시나리오로 성능테스트를 진행했다. K6를 활용하여 그라파나와 연결해 시각화하는 성능테스트를 구축했다.

시나리오

인기 트레이너의 오후 2시 예약 테스트

  • 10명의 회원이 동시에 예약 버튼 클릭
  • 예상 결과 : 1명 성공, 9명 실패 (동시성 제어)
  • 검증 필요 : Race Condition 방지, 데이터 일관성 유지

K6 + 그라파나

  • 실시간 모니터링 : InfluxDB + Grafana 연동
  • 시나리오 기반 테스트: 복잡한 사용자 플로우 구현 가능

테스트 환경 구성

아키텍처 다이어그램

image

도커 환경 설정

docker-compose.yml

version: '3.8'
services:
  # 애플리케이션 데이터베이스
  mysql:
    image: mysql:8.0.36
    ports:
      - "3308:3306"
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: fitpass
    volumes:
      - mysql_data:/var/lib/mysql

  # 성능 메트릭 저장소
  influxdb:
    image: influxdb:1.8
    ports:
      - "8086:8086"
    environment:
      INFLUXDB_DB: k6
      INFLUXDB_USER: k6
      INFLUXDB_USER_PASSWORD: k6
    volumes:
      - influxdb_data:/var/lib/influxdb

  # 모니터링 대시보드
  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - ./grafana/dashboards:/var/lib/grafana/dashboards
      - ./grafana/provisioning:/etc/grafana/provisioning
      - grafana_data:/var/lib/grafana

volumes:
  mysql_data:
  influxdb_data:
  grafana_data:

K6 테스트 시나리오 설계

사용자 정의

1. 로그인: 테스트 사용자 10명이 각각 로그인
2. 동시 예약: 모든 사용자가 같은 시간대에 예약 시도
3. 결과 확인: 성공/실패 여부와 응답 시간 측정

테스트 스크립트 작성

  • 스크립트

    import http from 'k6/http';
    import { check, sleep } from 'k6';
    import { Trend, Counter, Rate } from 'k6/metrics';
    
    // 커스텀 메트릭 정의
    const reservationSuccessRate = new Rate('reservation_success_rate');
    const reservationDuration = new Trend('reservation_duration');
    const concurrentUsers = new Counter('concurrent_users');
    const conflictErrors = new Counter('conflict_errors');
    const authSuccessRate = new Rate('auth_success_rate');
    
    export const options = {
      scenarios: {
        concurrent_reservations: {
          executor: 'shared-iterations',
          vus: 10,
          iterations: 10,
          maxDuration: '30s',
        },
      },
      thresholds: {
        http_req_duration: ['p(95)<3000'],
        http_req_failed: ['rate<0.1'], // 실패율 10% 미만
        checks: ['rate>0.95'],
        reservation_success_rate: ['rate>=0.05'],
        auth_success_rate: ['rate>0.95'],
      },
      // InfluxDB 출력 설정
      ext: {
        loadimpact: {
          apm: []
        }
      }
    };
    
    // 테스트 사용자 계정 (10명)
    const testUsers = [
      '[email protected]',
      '[email protected]',
      '[email protected]',
      '[email protected]',
      '[email protected]',
      '[email protected]',
      '[email protected]',
      '[email protected]',
      '[email protected]',
      '[email protected]',
    ];
    
    // 예약 날짜: 오늘 기준 +3일, 시간 고정
    const reservationDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
    .toISOString()
    .split('T')[0];
    const reservationTime = '14:00';
    
    // 서버 URL
    const BASE_URL = 'http://localhost:8080';
    
    export function setup() {
      console.log('🎬 === FitPass 동시성 테스트 시작 ===');
      console.log(`🌐 서버 URL: ${BASE_URL}`);
      console.log(`📅 예약 날짜: ${reservationDate}`);
      console.log(`⏰ 예약 시간: ${reservationTime}`);
      console.log(`👥 동시 사용자 수: 10명`);
      console.log('⚡ 테스트 시작!\n');
    }
    
    export default function () {
      http.get('http://localhost:8080/reservations');  // 요청 이름 = "GET /reservations"
      concurrentUsers.add(1);
    
      const userIndex = (__VU - 1) % testUsers.length;
      const email = testUsers[userIndex];
      const password = 'password';
    
      // 로그인
      const loginPayload = JSON.stringify({ email, password });
      const loginRes = http.post(`${BASE_URL}/auth/login`, loginPayload, {
        headers: { 'Content-Type': 'application/json' },
        timeout: '10s',
      });
    
      const loginBody = loginRes.json();
    
      const loggedIn = check(loginRes, {
        '로그인 성공 (200)': (r) => r.status === 200,
        '토큰 있음': () => loginBody?.data?.accessToken?.length > 0,
      });
    
      authSuccessRate.add(loggedIn ? 1 : 0);
    
      if (!loggedIn) {
        console.log(`❌ [VU-${__VU}] 로그인 실패: ${email} (status: ${loginRes.status})`);
        if (loginRes.body) console.log(`   응답: ${loginRes.body}`);
        return;
      }
    
      const token = loginBody.data.accessToken;
      console.log(`✅ [VU-${__VU}] 로그인 성공: ${email}`);
    
      // 예약 시도
      const reservationPayload = JSON.stringify({
        reservationDate,
        reservationTime,
      });
    
      const startTime = Date.now();
      const reservationRes = http.post(
          `${BASE_URL}/gyms/1/trainers/1/reservations`,
          reservationPayload,
          {
            headers: {
              Authorization: token,
              'Content-Type': 'application/json',
            },
            timeout: '15s',
          }
      );
      const duration = Date.now() - startTime;
      reservationDuration.add(duration);
    
      const reservationCheck = check(reservationRes, {
        '응답 받음': (r) => r.status !== 0,
        '예약 성공 (201)': (r) => {
          if (r.status === 201) {
            console.log(`🎉 [VU-${__VU}] 예약 성공! ${email} (${duration}ms)`);
            reservationSuccessRate.add(1);
            return true;
          }
          return false;
        },
        '중복 예약 (409)': (r) => {
          if (r.status === 409) {
            console.log(`⚠️ [VU-${__VU}] 중복 예약 발생: ${email} (${duration}ms)`);
            conflictErrors.add(1);
            reservationSuccessRate.add(0);
            return true;
          }
          return false;
        },
        '잘못된 요청 (400)': (r) => r.status === 400,
        '인증 실패 (401)': (r) => r.status === 401,
      });
    
      if (!reservationCheck) {
        console.log(`💥 [VU-${__VU}] 예약 실패: ${email} (status: ${reservationRes.status}, ${duration}ms)`);
        if (reservationRes.body) console.log(`   응답: ${reservationRes.body}`);
      }
    
      if (duration > 3000) {
        console.log(`🐌 [VU-${__VU}] 느린 응답: ${email} - ${duration}ms (3초 초과)`);
      } else if (duration < 500) {
        console.log(`⚡ [VU-${__VU}] 빠른 응답: ${email} - ${duration}ms`);
      }
    
      sleep(0.1);
    }
    
    export function teardown() {
      console.log('\n🏁 === 테스트 완료 ===');
      console.log('📈 결과는 Grafana 대시보드에서 확인하세요!');
      console.log('🔗 http://localhost:3000');
    }
    
    export function handleSummary(data) {
      console.log('\n🏁 === 동시성 테스트 결과 요약 ===');
      console.log(`📊 총 테스트 횟수: ${data.metrics.iterations.count}`);
      console.log(`⏱ 평균 응답시간: ${data.metrics.http_req_duration.avg.toFixed(2)}ms`);
      console.log(`🏆 예약 성공률: ${(data.metrics.reservation_success_rate.rate * 100).toFixed(1)}%`);
      console.log(`🔐 로그인 성공률: ${(data.metrics.auth_success_rate.rate * 100).toFixed(1)}%`);
      console.log(`⚔️ 충돌 수: ${data.metrics.conflict_errors.count || 0}`);
      console.log(`🎯 예상: 1명 예약 성공, 9명은 충돌 또는 실패`);
    
      return {
        stdout: JSON.stringify(data, null, 2),
      };
    }
    
    

테스트 결과

테스트 조건
- 동시 사용자: 10명 (VU 1-10)
- 예약 대상: 2025-07-03 14:00 (트레이너 ID: 1)
- 테스트 지속시간: 0.5초
- 실행 모드: shared-iterations (10회 반복)

핵심 성능 지표
- 총 HTTP 요청: 30개
  ├── 로그인 요청: 10개 (모두 성공)
  ├── 예약 성공: 1개 ([email protected], 73ms)
  └── 예약 충돌: 9개 (Status 409)

상세 응답시간 분석
- 평균 응답시간: 124.92ms
- 중간값: 126.37ms
- 95% 응답시간: 248.89ms
- 최대 응답시간: 253.43ms
- 처리량: 55.83 RPS

동시성 제어 효과 (완벽!)
- 예약 성공률: 10% (1/10명)
- 충돌 감지율: 90% (9/10명)
- 인증 성공률: 100% (10/10명)
- 데이터 일관성: 완벽 유지

실시간 로그 분석
[email protected]: 73ms로 가장 빠른 응답, 예약 성공!
[email protected]: 88ms, "해당 시간에 이미 예약이 존재합니다"
[email protected]: 103ms, 동일한 충돌 메시지
[email protected]: 171ms, 가장 느린 응답이지만 정상 처리

동시성 제어 테스트 성공

  • 10명 중 정확히 1명만 예약 성공
    • [email protected]이 73ms의 가장 빠른 응답으로 예약 성공
    • 나머지 9명은 모두 409 Conflict로 정상 차단
  • 빠른 응답시간 : 전체 평균 124.92ms
    • 예약 성공 : 73ms (매우 빠름)
    • 충돌 감지 : 88~171ms
    • 95% 응답시간 : 248.89ms
  • 안정적인 처리량 : 55.83 RPS
    • 0.5초 동안 30개 요청 완벽 처리
    • 피크 시간대에도 충분한 처리 능력 입증
  • 로그인 성공률 100%
    • 10명 모두 즉시 로그인 성공
    • JWT 토큰 발급 및 검증 정상

로그

INFO[0000] 🎉 [VU-9] 예약 성공! [email protected] (73ms)         source=console
INFO[0000]    응답: {"statusCode":201,"message":"예약이 완료되었습니다.","data":{"reservationId":3,"userId":11,"gymId":1,"trainerId":1,"reservationDate":"2025-07-03","ree":"14:00","reservationStatus":"PENDING","createdAt":"2025-06-30 20:42:03"}}  source=console
INFO[0000] ⚠️ [VU-8] 중복 예약 발생: [email protected] (88ms)     source=console
INFO[0000] 💥 [VU-8] 예약 실패: [email protected] (status: 409, 88ms)  source=console
INFO[0000]    응답: {"status":409,"error":"Conflict","code":409,"message":"해당 시간에 이미 예약이 존재합니다.","path":"/gyms/1/trainers/1/reservations","timestamp":"202540872"}  source=console
INFO[0000] ⚠️ [VU-4] 중복 예약 발생: [email protected] (103ms)    source=console
INFO[0000] 💥 [VU-4] 예약 실패: [email protected] (status: 409, 103ms)  source=console
INFO[0000]    응답: {"status":409,"error":"Conflict","code":409,"message":"해당 시간에 이미 예약이 존재합니다.","path":"/gyms/1/trainers/1/reservations","timestamp":"2025561"}  source=console

마무리

  • 시나리오에 맞게 10명중 1명만 예약에 성공하는 로직 구현.
  • K6 + InfluxDB + Grafana 연동으로 실시간 성능 지표 확인할 수 있음.
  • 스크립트를 통해 반복 가능한 자동화 테스트환경
  • 빠른 응답시간 (평균 124ms, 95% 응답시간 249ms)

K6로 테스트 해본 예약 동시성 성능 테스트