헬스장 PT 예약 시스템을 개발하면서 동시성제어 부분에 성능테스트를 확실히 해야겠다고 생각이 들었다. "여러 회원이 동시에 같은 시간대에 한 트레이너에게 예약하면 어떻게 처리가 되지?" 라는 시나리오로 성능테스트를 진행했다. K6를 활용하여 그라파나와 연결해 시각화하는 성능테스트를 구축했다.
인기 트레이너의 오후 2시 예약 테스트
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'
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"
-- 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명만 예약 성공
빠른 응답시간 : 전체 평균 124.92ms
안정적인 처리량 : 55.83 RPS
로그인 성공률 100%
모니터링해야 할 지표들
http_req_failed: 63.33%
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을 방지했다.
📊 실시간 모니터링 패널
├── 🎯 총 요청 수 (Stat)
├── 📈 응답시간 추이 (Time Series)
├── 🥧 응답 상태 분포 (Pie Chart)
├── ⚡ 처리량 (RPS) (Time Series)
├── 👥 동시 사용자 수 (Time Series)
└── 🔍 커스텀 메트릭 (Stat)