K6로 테스트 해본 예약 동시성 성능 테스트 - fitpassTeam/fitpass GitHub Wiki
K6로 테스트 해본 예약 동시성 성능 테스트
배경 : 예약 동시성 제어 K6 + Grafana 테스트
개요
헬스장 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:
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)