본 문서는 SpeedCam 프로젝트의 가설 기반 부하 테스트 계획을 제공합니다.
- 6개 GCP e2-small 인스턴스 (2 vCPU, 2 GB RAM 각각):
- speedcam-app: Django + Gunicorn + MQTT Subscriber
- speedcam-db: MySQL 8.0
- speedcam-mq: RabbitMQ (MQTT plugin + AMQP)
- speedcam-ocr: Celery OCR Worker
- speedcam-alert: Celery Alert Worker
- speedcam-mon: Prometheus + Grafana + Loki + Jaeger
| 변수 | env.example 기본값 | 실제 배포값 | 영향 |
|---|---|---|---|
GUNICORN_WORKERS |
4 | 2 | HTTP 처리 용량 절반 (8 → 4 핸들러) |
OCR_CONCURRENCY |
4 | 4 | 차이 없음 |
ALERT_CONCURRENCY |
100 | 100 | 차이 없음 |
주의: 본 문서의 모든 용량 계산은 실제 배포 환경 값 기준입니다.
| 컴포넌트 | 용량 | 산출 근거 |
|---|---|---|
| HTTP 동시 처리 | 4 핸들러 | 2 workers × 2 threads (GUNICORN_WORKERS=2) |
| 이론상 최대 HTTP RPS | ~40-80 | 4 핸들러 × (10-20 req/s, 응답 50-100ms 기준) |
| MQTT Subscriber 처리량 | ~50-100 msg/s | 단일 스레드: JSON 파싱 + DB 쓰기 + AMQP 발행 (~10-20ms/건) |
| OCR 파이프라인 (mock) | ~8-40 tasks/s | 4 workers × (2-10 tasks/s, mock sleep 0.1-0.5s) |
| OCR 파이프라인 (실제) | ~0.4-2 tasks/s | 4 workers × (0.1-0.5 tasks/s, EasyOCR ~2-10s) |
| Alert 파이프라인 | ~500-2000 tasks/s | 100 gevent workers × (5-20 tasks/s, FCM mock 기준) |
| MySQL max_connections | 151 | MySQL 8.0 기본값 |
| 예상 DB 연결 수 (부하 시) | ~12-20 | Gunicorn(4) + OCR(4) + Alert(100, pooled) + MQTT(1) |
RaspPi MQTT publish (QoS 1)
→ RabbitMQ MQTT plugin → Django MQTT Subscriber (단일 스레드, loop_forever)
→ Detection.create(status=pending) [detections_db]
→ process_ocr.apply_async(queue=ocr_queue, priority=5)
→ OCR Worker: GCS 다운로드 → EasyOCR → 차량 매칭 [vehicles_db]
→ Detection.update(status=completed) [detections_db]
→ send_notification.apply_async(queue=fcm_queue)
→ Alert Worker: FCM topic 브로드캐스트 + 개별 푸시
→ Notification.create() [notifications_db]
- MQTT Subscriber - 단일 스레드, 동기 DB 쓰기 (커넥션 풀링 없음)
- OCR Worker - CPU 바운드, 4개 동시 처리 한정
- Gunicorn - 4 핸들러, DB 연결 오버헤드
- MySQL - 커넥션 풀링 없음, 부하 시 연결 폭주
| 엔드포인트 | 설명 | 비고 |
|---|---|---|
GET /api/v1/detections/ |
감지 목록 (페이지네이션, PAGE_SIZE=20) | 대시보드 폴링 |
GET /api/v1/detections/pending/ |
대기/처리 중 감지 목록 (페이지네이션 없음) | 파이프라인 상태 확인 |
GET /api/v1/detections/statistics/ |
집계 통계 (total, completed, failed, pending, avg_speed, max_speed) | 파이프라인 완료 검증 |
GET /api/v1/notifications/ |
알림 목록 (페이지네이션) | 대시보드 폴링 |
POST /api/v1/vehicles/ |
차량 등록 | 관리자 작업 |
PATCH /api/v1/vehicles/{id}/fcm-token/ |
FCM 토큰 업데이트 | 관리자 작업 |
설명: 사용자가 대시보드를 열어놓고 주기적으로 데이터를 확인하는 패턴
트래픽 패턴:
- 3-5 VUs
GET /api/v1/detections/매 5초GET /api/v1/notifications/매 10초GET /api/v1/detections/statistics/매 30초
가설:
| 지표 | 예측값 | 근거 |
|---|---|---|
| p95 응답 시간 | < 200ms | 단순 페이지네이션 읽기, MySQL 인덱스 스캔 |
| 에러율 | 0% | 4 핸들러 용량 내 |
| 최대 RPS | 2-3 | sleep 간격으로 실제 RPS 매우 낮음 |
| 예상 병목 | 없음 | 4 핸들러 용량 범위 내 |
설명: 관리자가 간헐적으로 차량을 등록하고 FCM 토큰을 업데이트하는 패턴
트래픽 패턴:
- 1-2 VUs
POST /api/v1/vehicles/매 30초PATCH /api/v1/vehicles/{id}/fcm-token/매 60초
가설:
| 지표 | 예측값 | 근거 |
|---|---|---|
| p95 응답 시간 | < 300ms | 저빈도 쓰기, 경합 최소 |
| 에러율 | 0% | 매우 낮은 부하 |
| 최대 RPS | < 0.1 | 30-60초 간격 |
| 예상 병목 | 없음 | - |
설명: 대시보드 폴링 + 관리자 작업 + 파이프라인 상태 확인이 동시에 발생하는 패턴
트래픽 패턴:
- 5 VUs 대시보드 폴링 + 1 VU 관리자 + 3 VU
/api/v1/detections/pending/조회 - 총 9 VUs
가설:
| 지표 | 예측값 | 근거 |
|---|---|---|
| p95 응답 시간 | < 500ms | 9 VUs, sleep 간격으로 분산되나 피크 시 큐잉 발생 |
| 에러율 | < 1% | /pending/는 비페이지네이션, 응답 크기 증가 가능 |
| 최대 RPS | ~6-10 | sleep 간격으로 분산 |
| 예상 병목 | Gunicorn 핸들러 포화 (피크 시) | 9 VUs × 4 DBs = 최대 36 DB 연결 |
설명: 갑자기 사용자가 몰리는 상황 시뮬레이션
트래픽 패턴:
- Ramp: 0→3 (10s), 3→15 (10s), 15→15 (30s), 15→3 (10s), 3→0 (10s)
- 최대 15 VUs (4 핸들러 대비 ~3.75배 초과 구독)
가설:
| 지표 | 예측값 | 근거 |
|---|---|---|
| p95 응답 시간 | < 1500ms | 15 VUs >> 4 핸들러 → 심각한 요청 큐잉 |
| 에러율 | < 10% | 핸들러 포화 + MySQL 연결 생성 오버헤드 |
| 최대 RPS | ~10-15 | 큐잉 발생하지만 처리는 지속 |
| 예상 병목 | Gunicorn worker 포화 + MySQL 연결 폭주 | - |
설명: 평상시 트래픽. 20대 카메라가 분당 1건씩 감지
트래픽 패턴:
- 20 workers (카메라 1대 = 1 worker)
- 1 msg/min/worker = 총 0.33 msg/s
- 지속 시간: 120초
- 예상 총 메시지: 40건
가설:
| 지표 | 예측값 | 근거 |
|---|---|---|
| 발행 성공률 | 100% | 구독자 처리량 대비 극히 낮은 부하 |
| 파이프라인 완료 시간 | 60초 이내 (전체) | OCR worker 대부분 유휴 |
| DLQ 메시지 | 0 | 안정적 처리 예상 |
| 예상 병목 | 없음 | - |
설명: 교통 혼잡 시간. 20대 카메라가 분당 5건씩 감지
트래픽 패턴:
- 20 workers, 5 msg/min/worker = 총 1.67 msg/s
- 지속 시간: 120초
- 예상 총 메시지: 200건
가설:
| 지표 | 예측값 | 근거 |
|---|---|---|
| 발행 성공률 | 100% | 구독자 처리 범위 내 |
| 파이프라인 완료율 | 95% (120초 이내) | OCR 큐 약간 축적 (1.67 in vs 8-40 out) |
| DLQ 메시지 | 0 | - |
| 예상 병목 | OCR worker (mock 느린 경우) | - |
설명: 스트레스 테스트. 모든 카메라가 초당 1건씩 동시 감지
트래픽 패턴:
- 20 workers, 1 msg/sec/worker = 총 20 msg/s
- 지속 시간: 60초
- 예상 총 메시지: 1200건
가설:
| 지표 | 예측값 | 근거 |
|---|---|---|
| 발행 성공률 | 100% | RabbitMQ는 20 msg/s 충분히 처리 |
| OCR 큐 피크 깊이 | 200-500 | 20 msg/s 유입 vs OCR 4 concurrency (8-40/s) |
| 전체 드레인 시간 | 300초 이내 | 큐 축적 후 순차 처리 |
| 예상 병목 | MQTT Subscriber (단일 스레드 DB 쓰기) → OCR 큐 깊이 | - |
| 시나리오 | 유형 | 예측 최대 처리량 | 예측 p95 지연 | 예측 에러율 | 예측 병목 |
|---|---|---|---|---|---|
| A: 대시보드 폴링 | HTTP | 2-3 RPS | < 200ms | 0% | 없음 |
| B: 관리자 작업 | HTTP | < 0.1 RPS | < 300ms | 0% | 없음 |
| C: 혼합 워크로드 | HTTP | 6-10 RPS | < 500ms | < 1% | Gunicorn 핸들러 |
| D: 스파이크 내성 | HTTP | 10-15 RPS | < 1500ms | < 10% | Gunicorn + MySQL |
| A: 정상 운영 | MQTT | 0.33 msg/s | - | 0% | 없음 |
| B: 러시아워 | MQTT | 1.67 msg/s | - | 0% | OCR (경우에 따라) |
| C: 버스트 스톰 | MQTT | 20 msg/s | - | 0% | MQTT Subscriber → OCR |
- k6 설치 (HTTP 테스트용)
- Python 3 + paho-mqtt 패키지 (MQTT 테스트용)
- SpeedCam 서비스 전체 가동 중
- Grafana 대시보드 접속 가능 (모니터링용)
# 전체 시나리오 실행 (Prometheus Remote Write 포함)
# 주의: k6 v1.5.0에서 --out 플래그로 URL 전달 불가 → 환경변수 사용 필수
K6_PROMETHEUS_RW_SERVER_URL=http://10.178.0.5:9090/api/v1/write \
k6 run --out experimental-prometheus-rw \
--env MAIN_SERVICE_URL=http://localhost \
backend/docker/k6/load-test.js
# Prometheus Remote Write 없이 실행 (결과는 콘솔 출력만)
k6 run --env MAIN_SERVICE_URL=http://localhost \
backend/docker/k6/load-test.js참고: 실행 위치별 호스트 설정
실행 위치 MAIN_SERVICE_URLPrometheus RW URL speedcam-app (권장) http://localhosthttp://10.178.0.5:9090/api/v1/write외부 http://34.64.41.106http://34.47.70.132:9090/api/v1/writek6를 speedcam-app에서 실행하면 네트워크 지연 없이 측정 가능하나, k6 자체가 CPU/메모리를 소비하므로 결과에 영향을 줄 수 있습니다 (e2-small 공유).
speedcam-app 인스턴스에서 실행 시 내부 IP 사용:
speedcam-mq→10.178.0.7,speedcam-app→localhost
# 정상 운영 시나리오 (speedcam-app에서 실행)
python3 mqtt-load-test.py \
--scenario normal \
--mqtt-host 10.178.0.7 \
--mqtt-user sa --mqtt-pass $RABBITMQ_PASS \
--api-url http://localhost \
--rabbitmq-api http://10.178.0.7:15672 \
--rabbitmq-user sa --rabbitmq-pass $RABBITMQ_PASS
# 러시아워 시나리오
python3 mqtt-load-test.py \
--scenario rush_hour \
--mqtt-host 10.178.0.7 \
--mqtt-user sa --mqtt-pass $RABBITMQ_PASS \
--api-url http://localhost \
--rabbitmq-api http://10.178.0.7:15672 \
--rabbitmq-user sa --rabbitmq-pass $RABBITMQ_PASS
# 버스트 스톰 시나리오
python3 mqtt-load-test.py \
--scenario burst \
--mqtt-host 10.178.0.7 \
--mqtt-user sa --mqtt-pass $RABBITMQ_PASS \
--api-url http://localhost \
--rabbitmq-api http://10.178.0.7:15672 \
--rabbitmq-user sa --rabbitmq-pass $RABBITMQ_PASS
# 커스텀 설정 (하위 호환)
python3 mqtt-load-test.py \
--workers 10 --rate 5 --duration 30 \
--mqtt-host 10.178.0.7 --mqtt-user sa --mqtt-pass $RABBITMQ_PASS# 터미널 1: HTTP 부하 (speedcam-app에서 실행)
K6_PROMETHEUS_RW_SERVER_URL=http://10.178.0.5:9090/api/v1/write \
k6 run --out experimental-prometheus-rw \
--env MAIN_SERVICE_URL=http://localhost \
load-test.js
# 터미널 2: MQTT 부하 (동시 실행)
python3 mqtt-load-test.py --scenario rush_hour \
--mqtt-host 10.178.0.7 --mqtt-user sa --mqtt-pass $RABBITMQ_PASS \
--api-url http://localhost \
--rabbitmq-api http://10.178.0.7:15672 \
--rabbitmq-user sa --rabbitmq-pass $RABBITMQ_PASS| 도구 | 내부 IP (GCE 내) | 외부 IP (브라우저) | 용도 |
|---|---|---|---|
| Grafana | 10.178.0.5:3000 |
34.47.70.132:3000 |
대시보드 (cAdvisor, k6, Django) |
| Prometheus | 10.178.0.5:9090 |
34.47.70.132:9090 |
메트릭 직접 쿼리 |
| RabbitMQ | 10.178.0.7:15672 |
34.64.183.199:15672 |
큐 깊이, 메시지 속도 |
| Flower | 10.178.0.4:5555 |
34.64.41.106:5555 |
Celery 태스크 현황 |
각 시나리오 실행 후 아래 표를 복사하여 채워 넣으세요.
시나리오: [시나리오 이름]
실행 일시: [YYYY-MM-DD HH:MM]
실행 환경: [인스턴스 사양, 특이사항]
| 지표 | 가설 | 실제 | 차이 | 분석 |
|------|------|------|------|------|
| 최대 처리량 (RPS/msg/s) | | | | |
| p95 응답 시간 | | | | |
| 에러율 | | | | |
| 예상 병목 | | | | |
| OCR 큐 피크 깊이 | | | | |
| FCM 큐 피크 깊이 | | | | |
| DLQ 메시지 수 | | | | |
| E2E 완료 시간 | | | | |
- Gunicorn worker 포화 여부 → Grafana: container CPU/memory for speedcam-app
- MySQL 연결 수 확인 →
SHOW PROCESSLIST또는 mysqld-exporter 메트릭 - RabbitMQ 큐 깊이 → Management UI: ocr_queue, fcm_queue, dlq_queue
- MQTT Subscriber 처리 지연 → APP 컨테이너 로그에서 on_message 처리 시간
- OCR Worker 처리 속도 → Celery exporter: task latency, queue length
- Alert Worker 처리 속도 → Celery exporter: fcm_queue task latency
- 디스크 I/O → cAdvisor: disk read/write bytes
- 메모리 부족 → cAdvisor: container memory usage vs limit
가설과 실제가 일치하는가?
├─ YES → 용량 한계를 정확히 파악함. 필요시 스케일링 계획 수립.
└─ NO → 왜 다른가?
├─ 실제가 가설보다 좋음 → 용량 계산이 보수적. 가설 수정 후 재테스트.
├─ 실제가 가설보다 나쁨
│ ├─ 에러율 높음 → 병목 체크리스트로 원인 파악
│ │ ├─ Gunicorn 포화 → GUNICORN_WORKERS 증가 또는 인스턴스 업그레이드
│ │ ├─ MySQL 연결 폭주 → CONN_MAX_AGE 설정 또는 커넥션 풀링 도입
│ │ ├─ OCR 큐 축적 → OCR_CONCURRENCY 증가 또는 인스턴스 분리
│ │ └─ MQTT Subscriber 병목 → 멀티스레드 처리 또는 비동기 DB 쓰기
│ └─ 지연시간 높음 → 동일 병목 체크리스트 적용
└─ 패턴이 예상과 다름 → 데이터 흐름 재분석, 로그 확인
실행 환경: speedcam-app (e2-small, 2 vCPU, 2 GB) 인스턴스에서 k6/MQTT 스크립트 실행 OCR 환경: 실제 EasyOCR (OCR_MOCK=false) — 가설의 mock 기반 예측과 크게 상이 k6 Prometheus RW:
K6_PROMETHEUS_RW_SERVER_URL=http://10.178.0.5:9090/api/v1/write
총 5분 40초, 973 iterations, 2,297 requests, 6.75 req/s
| 시나리오 | 지표 | 가설 | 실측 | 판정 |
|---|---|---|---|---|
| A: 대시보드 폴링 (3 VUs) | p95 | < 200ms | 30.6ms | ✅ PASS |
| A: 대시보드 폴링 | 에러율 | < 1% | 0.00% | ✅ PASS |
| B: 관리자 작업 (2/min) | p95 | < 300ms | 23.23ms | ✅ PASS |
| C: 혼합 워크로드 (9 VUs) | pending p95 | < 500ms | 16.6ms | ✅ PASS |
| C: 혼합 워크로드 | statistics p95 | < 500ms | 42.42ms | ✅ PASS |
| D: 스파이크 (15 VUs) | p95 | < 1500ms | 40.54ms | ✅ PASS |
| D: 스파이크 | 에러율 | < 10% | 0.00% | ✅ PASS |
| 전체 | 에러율 | < 5% | 0.21% | ✅ PASS |
엔드포인트별 상세 레이턴시:
| 메트릭 | avg | min | med | max | p90 | p95 |
|---|---|---|---|---|---|---|
| dashboard_req_duration | 19.27ms | 9.73ms | 17.65ms | 118.73ms | 26.89ms | 30.6ms |
| detections_list_duration | 23.98ms | 14.01ms | 20.53ms | 162.31ms | 33.3ms | 43.29ms |
| statistics_req_duration | 23.44ms | 13.31ms | 20.4ms | 127.43ms | 34.03ms | 42.42ms |
| pending_read_duration | 13.08ms | 10.3ms | 12.55ms | 27.22ms | 15.12ms | 16.6ms |
| admin_req_duration | 17.78ms | 4.21ms | 17.82ms | 53.17ms | 21.05ms | 23.23ms |
| http_req_duration (전체) | 20.72ms | 3.75ms | 18.23ms | 162.31ms | 30.14ms | 38.85ms |
분석:
- 15 VUs 스파이크에서도 p95 40ms — 가설 1500ms 대비 37배 좋음
- 4 핸들러(Gunicorn 2w×2t)가 15 VUs를 충분히 소화
- 유일한 에러: FCM 토큰 업데이트 PATCH 5건 실패 (엔드포인트 호환 문제)
- 가설이 매우 보수적이었음 → 실제 포화점은 50+ VUs (이전 스트레스 테스트에서 확인)
중요: 아래 결과는 실제 EasyOCR 환경입니다. 가설은 OCR_MOCK=true 기준으로 작성되었으므로 직접 비교 시 주의.
| 시나리오 | 발행 성공 | 완료율 (300s) | OCR큐 피크 | 실효 OCR 처리속도 | DLQ |
|---|---|---|---|---|---|
| Normal (0.33 msg/s) | 40/40 (100%) | 32/40 (80%) | 25 | ~0.053 msg/s | 0 |
| Rush Hour (1.67 msg/s) | 200/200 (100%) | 22/200 (11%) | 202 | ~0.073 msg/s | 0 |
| Burst (20 msg/s) | 1200/1200 (100%) | 18/1200 (1.5%) | 1,381 | ~0.060 msg/s | 0 |
가설 vs 실측 비교:
| 지표 | Normal 가설 | Normal 실측 | Rush Hour 가설 | Rush Hour 실측 | Burst 가설 | Burst 실측 |
|---|---|---|---|---|---|---|
| 발행 성공률 | 100% | 100% ✅ | 100% | 100% ✅ | 100% | 100% ✅ |
| 완료율 | 100% | 80% ❌ | 95% | 11% ❌ | 100% (drain) | 1.5% ❌ |
| 완료 시간 | 60초 | 300초 TO ❌ | 120초 | 300초 TO ❌ | 300초 | 300초 TO ❌ |
| OCR큐 피크 | < 5 | 25 ❌ | < 50 | 202 ❌ | 200-500 | 1,381 ❌ |
| DLQ | 0 | 0 ✅ | 0 | 0 ✅ | 0 | 0 ✅ |
핵심 발견 — OCR 처리 속도:
- 가설 (OCR_MOCK): 8-40 tasks/s (prefork 4 workers × 2-10/s)
- 실측 (EasyOCR): ~0.06 msg/s = 약 17초/건 (가설 대비 133~667배 느림)
- 원인: e2-small (2 vCPU, 2 GB) 인스턴스에서 EasyOCR 모델 로딩 + 추론 → 메모리/CPU 제약
- 테스트 후 OCR 큐 잔여: 1,362건 (드레인에 약 6.3시간 소요 예상)
병목 순위 (실측 기반):
- OCR Worker — 실제 EasyOCR 처리 속도가 지배적 병목 (0.06 msg/s)
- MQTT Subscriber — 단일 스레드이나, OCR 대비 충분히 빠름 (20 msg/s 발행 성공)
- RabbitMQ — 메시지 버퍼링 정상, DLQ 0건
- Gunicorn/MySQL — HTTP 계층은 병목 아님 (p95 < 50ms)
OCR_MOCK=true,FCM_MOCK=true환경에서 테스트 가정 (기본)- 실제 EasyOCR/FCM 사용 시 처리량이 크게 달라짐 (OCR: ~10-50배 느림)
- 모든 인스턴스 e2-small (2 vCPU, 2 GB RAM) — 프로덕션 환경에서는 스케일업 필요
- MySQL 커넥션 풀링 미설정 (
CONN_MAX_AGE=0) — 고부하 시 연결 오버헤드 발생 - MQTT Subscriber 단일 스레드 — 메시지 처리 직렬화됨
- k6를 대상 서버와 동일 인스턴스에서 실행하면 CPU/메모리 경합 발생 (별도 인스턴스 권장)