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

김현정·2025년 7월 1일
0

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

개요

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

성능테스트

시나리오

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

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

K6 + 그라파나

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

테스트 환경 구성

아키텍처 다이어그램

도커 환경 설정

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:

환경 시작하기

# 모든 서비스 시작
docker-compose up -d

# 서비스 상태 확인
docker-compose ps

# Spring Boot 애플리케이션 시작 (별도 터미널)
./gradlew bootRun --args='--spring.profiles.active=local'

K6 테스트 시나리오 설계

사용자 정의

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

테스트 스트립트

export const options = {
  // 테스트 시나리오 정의
  scenarios: {
    concurrent_reservations: {
      executor: 'shared-iterations',
      vus: 10,           // 가상 사용자 10명
      iterations: 10,    // 총 10번 반복
      maxDuration: '30s' // 최대 30초 내 완료
    }
  },

  // 성능 임계값 설정
  thresholds: {
    // 95%의 요청이 3초 이내 응답
    http_req_duration: ['p(95)<3000'],

    // 실패율 10% 미만
    http_req_failed: ['rate<0.1'],

    // 예약 성공률 5% 이상 (10명 중 1명 이상)
    reservation_success_rate: ['rate>=0.05']
  },

  // InfluxDB로 메트릭 전송
  output: 'influxdb=http://localhost:8086/k6'
};

커스텀 메트릭 정의

import { Counter, Rate } from 'k6/metrics';

// 사용자 정의 메트릭
export const reservationSuccessRate = new Rate('reservation_success_rate');
export const conflictCounter = new Counter('reservation_conflicts');
export const loginSuccessCounter = new Counter('login_success');

스크립트

인증 및 토큰 관리

import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';

// 테스트 사용자 데이터
const users = new SharedArray('users', function() {
  return [
    { email: 'user1@test.com', password: 'password' },
    { email: 'user2@test.com', password: 'password' },
    { email: 'user3@test.com', password: 'password' },
    // ... 10명의 사용자
  ];
});

export default function() {
  const BASE_URL = 'http://localhost:8080';
  const user = users[__VU - 1]; // VU별로 다른 사용자 할당

  // 1단계: 로그인
  const loginPayload = {
    email: user.email,
    password: user.password
  };

  const loginResponse = http.post(
    `${BASE_URL}/auth/login`,
    JSON.stringify(loginPayload),
    {
      headers: {
        'Content-Type': 'application/json',
      },
    }
  );

  // 로그인 성공 여부 확인
  const loginSuccess = check(loginResponse, {
    'login successful': (r) => r.status === 200,
    'login response time < 1000ms': (r) => r.timings.duration < 1000,
  });

  if (!loginSuccess) {
    console.error(`User ${user.email} login failed: ${loginResponse.status}`);
    return;
  }

  // JWT 토큰 추출
  const authToken = loginResponse.json('token');
  loginSuccessCounter.add(1);

  // 2단계: 예약 시도 (동시성 테스트의 핵심!)
  const reservationPayload = {
    trainerId: 1,
    reservationDate: '2025-07-03',
    reservationTime: '14:00'
  };

  const reservationResponse = http.post(
    `${BASE_URL}/api/reservations`,
    JSON.stringify(reservationPayload),
    {
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${authToken}`,
      },
    }
  );

  // 예약 결과 분석
  const reservationResult = check(reservationResponse, {
    'reservation request completed': (r) => r.status !== 0,
    'reservation successful': (r) => r.status === 201,
    'reservation conflict (expected)': (r) => r.status === 409,
    'response time acceptable': (r) => r.timings.duration < 3000,
  });

  // 결과에 따른 메트릭 업데이트
  if (reservationResponse.status === 201) {
    reservationSuccessRate.add(1);
    console.log(`✅ User ${user.email} successfully made reservation!`);
  } else if (reservationResponse.status === 409) {
    conflictCounter.add(1);
    console.log(`⚠️ User ${user.email} got conflict (expected behavior)`);
  } else {
    console.error(`❌ User ${user.email} unexpected error: ${reservationResponse.status}`);
  }

  sleep(0.1); // 짧은 대기시간
}

실시간 데이터 확인

export function handleSummary(data) {
  // 테스트 완료 후 요약 출력
  console.log('=== 테스트 결과 요약 ===');
  console.log(`총 요청 수: ${data.metrics.http_reqs.count}`);
  console.log(`평균 응답시간: ${data.metrics.http_req_duration.avg.toFixed(2)}ms`);
  console.log(`95% 응답시간: ${data.metrics['http_req_duration{p(95)}'].toFixed(2)}ms`);
  console.log(`예약 성공 수: ${data.metrics.reservation_success_rate?.count || 0}`);
  console.log(`충돌 발생 수: ${data.metrics.reservation_conflicts?.count || 0}`);

  return {
    'summary.json': JSON.stringify(data, null, 2),
  };
}

테스트 실행

기본 실행

# 1. 단일 테스트
k6 run --out influxdb=http://localhost:8086/k6 concurrency-test.js

# 2. 상세 로그 포함
k6 run --out influxdb=http://localhost:8086/k6 --verbose concurrency-test.js

# 3. 환경변수 설정
export BASE_URL="http://localhost:8080"
export RESERVATION_DATE="2025-07-03"
export RESERVATION_TIME="14:00"
k6 run concurrency-test.js

자동화 스크립트

#!/bin/bash
# run-performance-test.sh

echo "FitPass 성능 테스트 시작"

# 환경 확인
echo "환경 상태 확인..."
curl -s http://localhost:8080/actuator/health | jq .status
curl -s http://localhost:8086/ping

# 이전 데이터 정리 (선택사항)
echo "이전 테스트 데이터 정리..."
curl -X POST "http://localhost:8086/query" \
  --data-urlencode "q=DROP DATABASE k6"
curl -X POST "http://localhost:8086/query" \
  --data-urlencode "q=CREATE DATABASE k6"

# 성능 테스트 실행
echo "동시성 테스트 실행..."
k6 run \
  --out influxdb=http://localhost:8086/k6 \
  --summary-export=test-results.json \
  concurrency-test.js

# 결과 확인
echo "결과 확인..."
curl -s "http://localhost:8086/query?db=k6&q=SELECT COUNT(*) FROM http_reqs" | jq .

echo "테스트 완료! Grafana에서 결과를 확인하세요: http://localhost:3000"

DB에 넣을 데이터

-- K6 테스트용 사용자 11명 생성 스크립트
-- 비밀번호는 모두 "password"로 통일 (BCrypt 인코딩 필요)

-- 1. 체육관 사장 (OWNER)
INSERT INTO users (
    user_image, email, password, name, phone, age, address, point_balance,
    gender, user_role, auth_provider, created_at, updated_at
) VALUES (
             null,
             'owner@test.com',
             '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -- "password" BCrypt 인코딩
             '체육관사장',
             '010-1111-1111',
             35,
             '서울시 강남구',
             100000,
             'MAN',
             'OWNER',
             'LOCAL',
             NOW(),
             NOW()
         );

-- 2. 트레이너
INSERT INTO users (
    user_image, email, password, name, phone, age, address, point_balance,
    gender, user_role, auth_provider, created_at, updated_at
) VALUES (
             null,
             'trainer@test.com',
             '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -- "password" BCrypt 인코딩
             '김트레이너',
             '010-2222-2222',
             28,
             '서울시 서초구',
             100000,
             'MAN',
             'USER',
             'LOCAL',
             NOW(),
             NOW()
         );

-- 3-12. K6 테스트용 일반 사용자 10명
INSERT INTO users (
    user_image, email, password, name, phone, age, address, point_balance,
    gender, user_role, auth_provider, created_at, updated_at
) VALUES
      (null, 'user1@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저1', '010-0001-0001', 25, '서울시 강남구', 100000, 'MAN', 'USER', 'LOCAL', NOW(), NOW()),
      (null, 'user2@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저2', '010-0002-0002', 26, '서울시 서초구', 100000, 'WOMAN', 'USER', 'LOCAL', NOW(), NOW()),
      (null, 'user3@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저3', '010-0003-0003', 27, '서울시 송파구', 100000, 'MAN', 'USER', 'LOCAL', NOW(), NOW()),
      (null, 'user4@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저4', '010-0004-0004', 28, '서울시 강동구', 100000, 'WOMAN', 'USER', 'LOCAL', NOW(), NOW()),
      (null, 'user5@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저5', '010-0005-0005', 29, '서울시 마포구', 100000, 'MAN', 'USER', 'LOCAL', NOW(), NOW()),
      (null, 'user6@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저6', '010-0006-0006', 30, '서울시 용산구', 100000, 'WOMAN', 'USER', 'LOCAL', NOW(), NOW()),
      (null, 'user7@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저7', '010-0007-0007', 31, '서울시 성동구', 100000, 'MAN', 'USER', 'LOCAL', NOW(), NOW()),
      (null, 'user8@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저8', '010-0008-0008', 32, '서울시 중구', 100000, 'WOMAN', 'USER', 'LOCAL', NOW(), NOW()),
      (null, 'user9@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저9', '010-0009-0009', 33, '서울시 종로구', 100000, 'MAN', 'USER', 'LOCAL', NOW(), NOW()),
      (null, 'user10@test.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '테스트유저10', '010-0010-0010', 34, '서울시 동작구', 100000, 'WOMAN', 'USER', 'LOCAL', NOW(), NOW());

-- 사용자 확인 쿼리
SELECT id, email, name, user_role, auth_provider FROM users WHERE email LIKE '%test.com' ORDER BY id;

-- 비밀번호 확인용 (모두 "password"로 설정됨)
-- BCrypt 해시: $2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi

-- K6 테스트용 체육관과 트레이너 생성 스크립트

-- 1. 체육관 생성 (owner@test.com 사용자 소유)
INSERT INTO gyms (
    name, number, content, summary, city, district, detail_address,
    open_time, close_time, gym_status, gym_post_status, user_id,
    created_at, updated_at
) VALUES (
             '테스트 피트니스',
             '123-45-67890',
             '최고의 PT 서비스를 제공하는 체육관입니다. 전문 트레이너와 함께 건강한 삶을 시작하세요!',
             '강남 최고의 프리미엄 피트니스',
             '서울시',
             '강남구',
             '테헤란로 123-45',
             '06:00:00',
             '23:00:00',
             'OPEN',
             'APPROVED',
             (SELECT id FROM users WHERE email = 'owner@test.com'),
             NOW(),
             NOW()
         );

-- 2. 트레이너 생성 (위에서 생성한 체육관에 소속)
INSERT INTO trainers (
    name, price, content, experience, trainer_status, gym_id,
    created_at, updated_at
) VALUES (
             '김트레이너',
             50000,
             '10년 경력의 전문 퍼스널 트레이너입니다. 체형 교정, 근력 증진, 다이어트 전문으로 고객 맞춤형 운동 프로그램을 제공합니다.',
             '- 생활스포츠지도사 2급\n- NSCA-CPT 자격증\n- 요가지도자 자격증\n- 10년간 500명 이상 PT 지도 경험\n- 체형교정 및 재활운동 전문',
             'ACTIVE',
             (SELECT id FROM gyms WHERE name = '테스트 피트니스'),
             NOW(),
             NOW()
         );

-- 확인 쿼리
SELECT
    g.id as gym_id,
    g.name as gym_name,
    g.gym_status,
    u.email as owner_email,
    t.id as trainer_id,
    t.name as trainer_name,
    t.price as trainer_price
FROM gyms g
         JOIN users u ON g.user_id = u.id
         LEFT JOIN trainers t ON t.gym_id = g.id
WHERE g.name = '테스트 피트니스';

-- K6 스크립트에서 사용할 정보
-- 체육관 ID: 1 (보통 첫 번째로 생성되므로)
-- 트레이너 ID: 1 (보통 첫 번째로 생성되므로)
-- 예약 URL: /gyms/1/trainers/1/reservations

실제 테스트 결과 분석

성공적인 동시성 제어

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

📈 핵심 성능 지표
- 총 HTTP 요청: 30개
  ├── 로그인 요청: 10개 (모두 성공)
  ├── 예약 성공: 1개 (user9@test.com, 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명)
- 데이터 일관성: 완벽 유지

📊 실시간 로그 분석
user9@test.com: 73ms로 가장 빠른 응답, 예약 성공!
user8@test.com: 88ms, "해당 시간에 이미 예약이 존재합니다"
user4@test.com: 103ms, 동일한 충돌 메시지
user10@test.com: 171ms, 가장 느린 응답이지만 정상 처리

동시성 제어 테스트 성공

  • 10명 중 정확히 1명만 예약 성공

    • user9@test.com이 73ms의 가장 빠른 응답으로 예약 성공
    • 나머지 9명은 모두 409 Conflict로 정상 차단
  • 빠른 응답시간 : 전체 평균 124.92ms

    • 예약 성공 : 73ms (매우 빠름)
    • 충돌 감지 : 88~171ms
    • 95% 응답시간 : 248.89ms
  • 안정적인 처리량 : 55.83 RPS

    • 0.5초 동안 30개 요청 완벽 처리
    • 피크 시간대에도 충분한 처리 능력 입증
  • 로그인 성공률 100%

    • 10명 모두 즉시 로그인 성공
    • JWT 토큰 발급 및 검증 정상

모니터링해야 할 지표들

  • Threshold 실패: http_req_failed: 63.33%
    • 하지만 이는 409 Conflict를 실패로 카운트한 것
    • 비즈니스 로직상 정상적인 동작임
  • Check 실패: checks: 57.14%
    • 예약 실패를 체크 실패로 간주
    • 실제로는 동시성 제어의 성공적 작동

핵심 지표

INFO[0000] 🎉 [VU-9] 예약 성공! user9@test.com (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] 중복 예약 발생: user8@test.com (88ms)     source=console
INFO[0000] 💥 [VU-8] 예약 실패: user8@test.com (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] 중복 예약 발생: user4@test.com (103ms)    source=console
INFO[0000] 💥 [VU-4] 예약 실패: user4@test.com (status: 409, 103ms)  source=console
INFO[0000]    응답: {"status":409,"error":"Conflict","code":409,"message":"해당 시간에 이미 예약이 존재합니다.","path":"/gyms/1/trainers/1/reservations","timestamp":"2025561"}  source=console

DB 유니크 제약조건과 Redis 락이 협동하여 Race Condition을 방지했다.

Grafana 대시보드

핵심 메트릭 패널

📊 실시간 모니터링 패널
├── 🎯 총 요청 수 (Stat)
├── 📈 응답시간 추이 (Time Series)
├── 🥧 응답 상태 분포 (Pie Chart)
├── ⚡ 처리량 (RPS) (Time Series)
├── 👥 동시 사용자 수 (Time Series)
└── 🔍 커스텀 메트릭 (Stat)

마무리

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

0개의 댓글