diff --git a/README.md b/README.md index b270cef..ab3801d 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,13 @@ # 과속 차량 감지 및 알림 시스템 — Backend -### Software Design Document - | 항목 | 내용 | |------|------| | **Authors** | 이상훈 (Backend Lead / DevOps) | | **Status** | Living Document | -| **Last Updated** | 2026-02-19 | +| **Last Updated** | 2026-03-21 | | **Repository Scope** | Django API, Celery Worker 소스코드, Dockerfile, 로컬 개발 환경 | ---- - -## 목차 - -1. [배경 및 범위](#1-배경-및-범위-context--scope) -2. [목표 및 비목표](#2-목표-및-비목표-goals--non-goals) -3. [시스템 아키텍처](#3-시스템-아키텍처-system-architecture) -4. [상세 설계](#4-상세-설계-detailed-design) -5. [대안 분석](#5-대안-분석-alternatives-considered) -6. [공통 관심사](#6-공통-관심사-cross-cutting-concerns) -7. [기술 스택](#7-기술-스택) -8. [프로젝트 구조](#8-프로젝트-구조) -9. [로컬 개발 환경](#9-로컬-개발-환경) -10. [성능 및 부하 테스트](#10-성능-및-부하-테스트) -11. [팀](#11-팀) -12. [관련 문서](#12-관련-문서) -13. [변경 이력](#13-변경-이력) - ---- - -## 1. 배경 및 범위 (Context & Scope) - -Qualcomm 기반 Rubik Pi 엣지 디바이스에서 YOLO 객체 감지와 속도 측정을 통해 과속 차량을 탐지하고, Google Cloud Storage에 이미지를 업로드한 뒤 MQTT 프로토콜로 서버에 전송한다. 서버는 EasyOCR로 번호판을 인식하고, 매칭된 차량 소유자에게 Firebase Cloud Messaging 푸시 알림을 전송한다. - -이 문서는 **백엔드 시스템**의 설계를 다룬다. 엣지 디바이스(Rubik Pi)와 프론트엔드(React)는 범위에 포함하지 않는다. +Qualcomm 기반 Rubik Pi 엣지 디바이스에서 과속 차량을 감지하고 MQTT로 전송된 이벤트를 수신하여, EasyOCR로 번호판을 인식한 뒤 차량 소유자에게 Firebase Cloud Messaging 푸시 알림을 전송하는 백엔드 시스템이다. 엣지 디바이스(Rubik Pi)와 프론트엔드(React)는 범위에 포함하지 않는다. ### 저장소 책임 범위 @@ -48,47 +22,9 @@ Qualcomm 기반 Rubik Pi 엣지 디바이스에서 YOLO 객체 감지와 속도 --- -## 2. 목표 및 비목표 (Goals & Non-Goals) - -### Goals - -- **Event-Driven Architecture (Choreography Pattern)** 으로 서비스 간 느슨한 결합 달성 -- **MQTT 프로토콜**로 IoT 디바이스 통신 최적화 (경량, QoS 1, At-least-once) -- 서비스별 **독립 배포 및 수평 확장** 가능한 구조 -- **Database per Service** 패턴으로 데이터 격리 및 장애 전파 차단 -- OpenTelemetry 기반 **분산 트레이싱, 메트릭, 로그** 관측성 확보 -- Dead Letter Queue를 통한 **메시지 유실 방지** 및 장애 복구 - -### Non-Goals - -- 엣지 디바이스(Rubik Pi) 소프트웨어 설계 -- 프론트엔드(React) 설계 -- Kubernetes 기반 오케스트레이션 (현재 Docker Compose 기반) -- 실시간 영상 스트리밍 -- Multi-region 배포 - ---- - -## 3. 시스템 아키텍처 (System Architecture) - -### 3.1 아키텍처 진화: Before → After - -기존 모놀리식 구조에서 발견된 4가지 핵심 문제를 해결하기 위해 Event-Driven Architecture로 전환했다. - -| 영역 | Before | After | -|------|--------|-------| -| **OCR 처리** | Django 동기 (블로킹) | OCR Worker 비동기 (Celery prefork) | -| **응답 시간** | 3초+ | < 100ms | -| **IoT 프로토콜** | HTTP (오버헤드) | MQTT (경량, QoS 1) | -| **메시지 보장** | 없음 | At-least-once | -| **장애 격리** | 전체 영향 | 컴포넌트 격리 | -| **확장성** | 서버 전체 확장 | Worker별 독립 확장 | -| **데이터베이스** | 단일 DB | 서비스별 4개 DB | -| **Alert 처리** | Celery 직접 호출 (Orchestration) | Kombu Consumer + Celery gevent (Choreography) | +## 아키텍처 개요 -> 📸 캡처 1. 시스템 전체 아키텍처 다이어그램 (Before vs After) - -### 3.2 인스턴스 배포 구조 +### 인스턴스 배포 구조 6개의 GCE 인스턴스로 구성되며, 모두 `asia-northeast3-a` 리전에 배치된다. @@ -115,336 +51,15 @@ Qualcomm 기반 Rubik Pi 엣지 디바이스에서 YOLO 객체 감지와 속도 | `speedcam-alert` | 알림 처리 | Kombu Consumer + Celery Worker (gevent pool) | | `speedcam-mon` | 모니터링 | Prometheus, Grafana, Loki, Jaeger | -### 3.3 End-to-End 데이터 흐름 - -```mermaid -sequenceDiagram - participant Pi as Rubik Pi - participant GCS as Cloud Storage - participant MQTT as RabbitMQ
(MQTT) - participant Main as Main Service
(Django) - participant AMQP as RabbitMQ
(AMQP) - participant OCR as OCR Worker - participant DomEvt as domain_events
Exchange - participant Kombu as Kombu Consumer - participant Alert as Celery gevent
Worker - participant FCM as Firebase FCM - participant DDB as detections_db - participant VDB as vehicles_db - participant NDB as notifications_db - - Note over Pi: 과속 차량 감지 - - Pi->>GCS: 1. 이미지 업로드 - Pi->>MQTT: 2. MQTT Publish (detections/new, QoS 1) - - MQTT->>Main: 3. MQTT Subscribe - Main->>DDB: 4. Detection 생성 (status=pending) - Main->>AMQP: 5. process_ocr.apply_async() → ocr_queue - - AMQP->>OCR: 6. Consume from ocr_queue - OCR->>GCS: 7. 이미지 다운로드 - OCR->>OCR: 8. EasyOCR 실행 - OCR->>DDB: 9. OCR 결과 업데이트 (status=completed) - OCR->>VDB: 10. 번호판으로 Vehicle 조회 → vehicle_id 매핑 - OCR->>DomEvt: 11. detections.completed 이벤트 발행 (Choreography) - - DomEvt->>Kombu: 12. alert_domain_events 큐에서 수신 - Kombu->>AMQP: 13. send_notification.delay() → fcm_queue - - AMQP->>Alert: 14. Consume from fcm_queue (greenlet) - Alert->>DDB: 15. Detection 조회 - Alert->>VDB: 16. Vehicle/FCM 토큰 조회 - Alert->>FCM: 17. 푸시 전송 - Alert->>NDB: 18. 알림 이력 저장 -``` - ---- - -## 4. 상세 설계 (Detailed Design) - -### 4.1 메시징 아키텍처 - -두 가지 프로토콜을 목적에 따라 분리하여 사용한다. - -| 프로토콜 | 용도 | 특징 | -|----------|------|------| -| **MQTT** (Port 1883) | IoT → Main Service (`detections/new`) | 경량, QoS 1, Choreography 이벤트 전파 | -| **AMQP** (Port 5672) | Task 분배 + 도메인 이벤트 | Exchange/Queue 라우팅, DLQ 지원 | - -#### Exchange 설계 - -| Exchange | Type | Routing Key | 용도 | -|----------|------|-------------|------| -| `ocr_exchange` | direct | `ocr` | OCR Task 라우팅 | -| `fcm_exchange` | direct | `fcm` | 알림 Task 라우팅 | -| `domain_events` | topic | `detections.completed` | 도메인 이벤트 (Choreography) | -| `dlq_exchange` | fanout | - | Dead Letter 처리 | - -#### Queue 설계 - -| Queue | Exchange | DLQ | TTL | Max Priority | Prefetch | -|-------|----------|:---:|:---:|:---:|:---:| -| `ocr_queue` | ocr_exchange | ✅ | 1h | 10 | 1 | -| `fcm_queue` | fcm_exchange | ✅ | 1h | - | 10 | -| `alert_domain_events` | domain_events | ✅ | - | - | 1 | -| `dlq_queue` | dlq_exchange | - | - | - | 1 | - -- `ocr_queue`: Prefetch 1 — CPU 집약적 OCR은 한 번에 하나씩 처리 -- `fcm_queue`: Prefetch 10 — I/O 대기 시간을 활용하여 다수 메시지 프리페치 - -### 4.2 서비스별 설계 - -#### Main Service (`speedcam-app`) - -``` -[Gunicorn] [MQTT Subscriber] -workers=${GUNICORN_WORKERS} 백그라운드 스레드 (blocking loop) - -REST API 처리 detections/new 수신 -- 과속 내역 조회 → Detection pending 레코드 즉시 생성 -- 차량 등록/조회 → process_ocr.apply_async() -- 알림 이력 조회 -``` - -- Django + Gunicorn (`workers=${GUNICORN_WORKERS:-4}`, `threads=${GUNICORN_THREADS:-2}`) -- MQTT Subscriber: `paho-mqtt` 기반, 별도 스레드에서 `loop_forever()` 실행 -- `detections/new` 수신 시 즉시 `Detection(status=pending)` 레코드 생성 → 데이터 손실 방지 -- OTel instrumented (`speedcam-main`) - -#### OCR Service (`speedcam-ocr`) - -- Celery **prefork** pool (`concurrency=${OCR_CONCURRENCY:-2}`) -- CPU-bound 작업: GCS 다운로드 → EasyOCR + OpenCV → 번호판 파싱 -- 처리 완료 후 `domain_events` exchange에 `detections.completed` 발행 (Choreography) -- Mock 모드 지원 (`OCR_MOCK=true`) -- OTel instrumented (`speedcam-ocr`) - -#### Alert Service (`speedcam-alert`) — 2개 프로세스 구조 - -``` -┌─────────────────────────────┐ ┌──────────────────────────────────┐ -│ 프로세스 1: Kombu Consumer │ │ 프로세스 2: Celery gevent Worker │ -│ 단일 스레드 │ │ --pool=gevent │ -│ │ │ --concurrency=${ALERT_CONCURRENCY}│ -│ domain_events exchange에서 │ │ │ -│ detections.completed 구독 │ │ send_notification 태스크 처리 │ -│ │ │ greenlet 100개 동시 FCM 전송 │ -│ → send_notification.delay() │──→│ (I/O-bound 병렬 처리) │ -│ (즉시 반환, 비동기) │ │ │ -│ │ │ │ -│ OTel: speedcam-alert- │ │ OTel: speedcam-alert │ -│ consumer │ │ │ -└─────────────────────────────┘ └──────────────────────────────────┘ -``` - -- `start_alert_worker.sh`에서 두 프로세스를 `trap` 기반으로 lifecycle 관리 -- **필수 환경변수**: `OTEL_PYTHON_AUTO_INSTRUMENTATION_EXPERIMENTAL_GEVENT_PATCH=patch_all` - - OTel auto-instrumentation이 gevent monkey-patching보다 먼저 로드되면 Django DB thread-safety 이슈 발생 - - 이 환경변수로 OTel 초기화 전에 `gevent.monkey.patch_all()` 수행 - - 상세 분석: [docs/GEVENT_DB_THREAD_SAFETY.md](docs/GEVENT_DB_THREAD_SAFETY.md) -- Mock 모드 지원 (`FCM_MOCK=true`) - -### 4.3 데이터베이스 설계 (Database per Service) - -MSA 환경에서 각 서비스는 독립적인 데이터베이스를 사용하여 느슨한 결합을 유지한다. - -| 서비스 | 데이터베이스 | 용도 | -|--------|-------------|------| -| Django Core | `speedcam` | Auth, Admin, Sessions, Celery Results | -| Vehicles | `speedcam_vehicles` | 차량 정보, FCM 토큰 | -| Detections | `speedcam_detections` | 과속 감지 내역, OCR 결과 | -| Notifications | `speedcam_notifications` | 알림 전송 이력 | - -- **ForeignKey 대신 ID Reference**: MSA 원칙에 따라 cross-DB FK 관계를 사용하지 않음 -- **Django Database Router** (`config/db_router.py`): `app_label` 기반 자동 라우팅, `allow_relation = False` - -```mermaid -erDiagram - vehicles { - bigint id PK - varchar plate_number UK "번호판" - varchar owner_name "소유자명" - varchar owner_phone "연락처" - varchar fcm_token "FCM 토큰" - datetime created_at - datetime updated_at - } - - detections { - bigint id PK - bigint vehicle_id "차량 ID (ID Reference)" - float detected_speed "감지 속도" - float speed_limit "제한 속도" - varchar location "위치" - varchar camera_id "카메라 ID" - varchar image_gcs_uri "GCS 이미지 경로" - varchar ocr_result "OCR 결과" - float ocr_confidence "OCR 신뢰도" - datetime detected_at "감지 시간" - datetime processed_at "처리 완료 시간" - enum status "pending/processing/completed/failed" - text error_message - datetime created_at - datetime updated_at - } - - notifications { - bigint id PK - bigint detection_id "감지 ID (ID Reference)" - varchar fcm_token "FCM 토큰" - varchar title "알림 제목" - text body "알림 내용" - datetime sent_at "전송 시간" - enum status "pending/sent/failed" - int retry_count "재시도 횟수" - text error_message - datetime created_at - } -``` - -``` -┌─────────────────┐ ID Reference ┌─────────────────┐ -│ vehicles_db │ ◄───────────────────── │ detections_db │ -│ Vehicle │ vehicle_id │ Detection │ -└─────────────────┘ └────────┬────────┘ - │ ID Reference - │ detection_id - ┌────────▼────────┐ - │notifications_db │ - │ Notification │ - └─────────────────┘ -``` - -### 4.4 Celery 설정 - -| 설정 | 값 | 이유 | -|------|-----|------| -| `task_serializer` | json | 범용성, 디버깅 용이 | -| `timezone` | Asia/Seoul | 한국 시간대 기준 | -| `task_acks_late` | True | Worker 비정상 종료 시 메시지 재전달 | -| `task_reject_on_worker_lost` | True | Worker 소실 시 메시지 reject → DLQ | -| `task_time_limit` | 300s | Hard timeout | -| `task_soft_time_limit` | 240s | Soft timeout (SoftTimeLimitExceeded) | -| `worker_prefetch_multiplier` | 1 | 공정한 분배 | - -Task 라우팅: - -| Task | Queue | Exchange | -|------|-------|----------| -| `tasks.ocr_tasks.process_ocr` | `ocr_queue` | `ocr_exchange` | -| `tasks.notification_tasks.send_notification` | `fcm_queue` | `fcm_exchange` | -| `tasks.dlq_tasks.process_dlq_message` | `dlq_queue` | `dlq_exchange` | - ---- - -## 5. 대안 분석 (Alternatives Considered) - -### 5.1 Choreography vs Orchestration - -| 항목 | Choreography (선택) | Orchestration | -|------|:---:|:---:| -| **구조** | 각 서비스가 자율적으로 동작 | 중앙 Orchestrator가 제어 | -| **결합도** | 느슨한 결합 ✅ | 강한 결합 | -| **확장성** | 서비스별 독립 확장 ✅ | Orchestrator 병목 가능 | -| **장애 격리** | 영향 최소 ✅ | 중앙 장애 시 전체 중단 | -| **디버깅** | 흐름 추적 어려움 | 중앙 추적 용이 | - -**선택 이유**: 각 인스턴스(Main, OCR, Alert)가 독립적으로 배포/확장되며, OCR Worker가 직접 DB를 업데이트하여 Main Service 병목을 제거한다. 흐름 추적의 어려움은 OpenTelemetry 분산 트레이싱으로 보완한다. - -### 5.2 RabbitMQ vs Google Cloud Pub/Sub - -| 항목 | RabbitMQ (선택) | Cloud Pub/Sub | -|------|:---:|:---:| -| **MQTT 지원** | Plugin으로 지원 ✅ | 미지원 (별도 브릿지 필요) | -| **지연 시간** | 낮음 (VPC 내부) ✅ | 상대적으로 높음 | -| **비용** | 인스턴스 비용만 ✅ | 메시지 수 기반 과금 | -| **Priority Queue** | 지원 ✅ | 미지원 | -| **관리 부담** | 직접 운영 필요 | 완전 관리형 | - -**선택 이유**: Rubik Pi가 MQTT 프로토콜을 사용하므로 RabbitMQ MQTT Plugin으로 직접 연결할 수 있고, VPC 내부 통신으로 낮은 지연 시간을 확보한다. - -### 5.3 prefork vs gevent Pool - -| 항목 | prefork | gevent | -|------|---------|--------| -| **방식** | 멀티프로세싱 | 코루틴 (Greenlet) | -| **GIL 영향** | 회피 가능 ✅ | 영향 받음 | -| **적합한 작업** | CPU-bound ✅ | I/O-bound ✅ | -| **동시성** | 프로세스 수 제한 | 수천 개 가능 | - -**적용 전략**: - -| Worker | Pool | 이유 | -|--------|------|------| -| OCR Worker | `prefork` | EasyOCR은 CPU 집약적, GIL 회피 필요 | -| Alert Worker | `gevent` | FCM API 호출은 I/O 대기, 높은 동시성 필요 | - -### 5.4 Single DB vs Database per Service +### End-to-End 데이터 흐름 -| 항목 | Single DB | Database per Service (선택) | -|------|:---:|:---:| -| **결합도** | 높음 (스키마 공유) | 낮음 ✅ | -| **독립 배포** | 어려움 | 가능 ✅ | -| **데이터 일관성** | 트랜잭션 보장 | 최종 일관성 | -| **조인 쿼리** | 가능 | 불가 (Application Join) | +Rubik Pi가 GCS에 이미지를 업로드하고 MQTT로 감지 이벤트를 발행하면, Main Service가 `Detection(status=pending)` 레코드를 즉시 생성하고 OCR Task를 큐에 전달한다. OCR Worker가 번호판을 인식한 뒤 `domain_events` exchange에 `detections.completed` 이벤트를 발행하면(Choreography), Alert Service의 Kombu Consumer가 이를 수신하여 Celery gevent Worker를 통해 FCM 푸시 알림을 전송하고 알림 이력을 저장한다. -**선택 이유**: MSA 원칙 준수로 서비스 간 느슨한 결합을 달성하고, 한 서비스의 DB 장애가 다른 서비스에 영향을 최소화한다. +상세 시퀀스 다이어그램은 [docs/ARCHITECTURE_COMPARISON.md](docs/ARCHITECTURE_COMPARISON.md)를 참고한다. --- -## 6. 공통 관심사 (Cross-cutting Concerns) - -### 6.1 관측성 (Observability) - -| 영역 | 도구 | 용도 | -|------|------|------| -| **Metrics** | Prometheus + Grafana + cAdvisor | 시스템/컨테이너 메트릭 (11 targets) | -| **Logging** | Loki + Promtail v3.3.2 | 중앙 집중식 로그 수집 (16 containers) | -| **Tracing** | OpenTelemetry + Jaeger | 분산 트레이싱 (서비스 간 요청 추적) | -| **Task Monitoring** | Flower | Celery Task 상태 모니터링 | -| **Queue Dashboard** | RabbitMQ Management | Queue 상태 확인 | - -> 📸 캡처 2. Grafana 대시보드 (시스템 메트릭 개요) - -> 📸 캡처 3. Jaeger 트레이싱 (E2E 요청 추적 — MQTT 수신부터 FCM 전송까지) - -> 📸 캡처 4. RabbitMQ Management (Queue 상태 및 메시지 처리량) - -### 6.2 CI/CD - -**CI** (이 저장소 — GitHub Actions): - -| Workflow | 내용 | -|----------|------| -| `lint.yml` | flake8, black, isort 코드 품질 검사 | -| `test.yml` | pytest (main, ocr, alert 3개 워크플로우) | -| `docker-build.yml` | 3개 Docker 이미지 빌드 검증 | - -- Trigger: `push` / `pull_request` to `develop` -- Python 3.12, pip cache 활용 - -**CD**: 별도 deploy 저장소에서 관리 - -### 6.3 데이터 손실 방지 - -- **MQTT QoS 1**: At-least-once 전달 보장 -- **Pending 레코드 즉시 생성**: MQTT 메시지 수신 즉시 `Detection(status=pending)` 생성 → OCR 실패해도 감지 사실 추적 가능 -- **Dead Letter Queue**: 실패한 메시지를 `dlq_queue`로 라우팅하여 별도 처리 -- **Celery acks_late**: Worker 비정상 종료 시 메시지가 재전달됨 -- **task_reject_on_worker_lost**: Worker 소실 시 메시지 reject → DLQ 전달 - -### 6.4 보안 - -- MQTT/AMQP 인증 필수 (`RABBITMQ_MQTT_ALLOW_ANONYMOUS=false`) -- GCP Service Account 기반 GCS/Firebase 인증 -- `credentials/` 디렉토리 Git 제외 (`.gitignore`) -- CORS 설정으로 허용 Origin 제한 -- Django `SECRET_KEY` 환경변수 관리 - ---- - -## 7. 기술 스택 +## 기술 스택 | 구분 | 기술 | 버전 | |------|------|------| @@ -468,7 +83,7 @@ Task 라우팅: --- -## 8. 프로젝트 구조 +## 프로젝트 구조 각 서비스는 동일한 코드베이스를 공유하되, 실행 시 역할에 따라 다른 컴포넌트만 활성화한다. @@ -542,7 +157,7 @@ backend/ --- -## 9. 로컬 개발 환경 +## 로컬 개발 환경 ### Quick Start @@ -583,66 +198,32 @@ docker-compose up -d --build --- -## 10. 성능 및 부하 테스트 - -### 테스트 도구 - -Python 기반 MQTT 부하 테스트 스크립트 (`docker/k6/mqtt-load-test.py`)를 사용하여 E2E 파이프라인을 검증한다. - -### 테스트 시나리오 - -| 시나리오 | Workers | Rate | Duration | 총 메시지율 | -|----------|:---:|:---:|:---:|:---:| -| `normal` | 20 | 1/min | 120s | ~0.33 msg/s | -| `rush_hour` | 20 | 5/min | 120s | ~1.67 msg/s | -| `burst` | 20 | 1/s | 60s | ~20 msg/s | +## CI -### 검증 방법 +GitHub Actions를 통해 `develop` 브랜치에 대한 `push` 및 `pull_request` 시 자동으로 실행된다. -- Django API 폴링: Detection 상태 변화 추적 (pending → completed) -- RabbitMQ Management API: Queue 메시지 소비 확인 -- PipelineVerifier: 발행/수신/처리/알림 각 단계 검증 - -### 실측 결과 - -상세 테스트 계획 및 분석 결과는 별도 문서를 참고한다: -- [부하 테스트 계획](docs/load-test-plan.md) -- [성능 분석](docs/performance-analysis.md) - -> 📸 캡처 5. 부하 테스트 실행 결과 (터미널 출력) +| Workflow | 내용 | +|----------|------| +| `lint.yml` | flake8, black, isort 코드 품질 검사 | +| `test.yml` | pytest (main, ocr, alert 3개 워크플로우) | +| `docker-build.yml` | 3개 Docker 이미지 빌드 검증 | -> 📸 캡처 6. 부하 테스트 중 Grafana 메트릭 (CPU, Memory, Queue depth) +CD는 별도 deploy 저장소에서 관리된다. --- -## 11. 팀 - -| 이름 | 역할 | GitHub | -|------|------|--------| -| 이상훈 | Leader / Backend / DevOps | [@lsh1215](https://github.com/lsh1215) | -| 진민우 | Rubik Pi / Tracking / YOLO | [@Jminu](https://github.com/Jminu) | -| 최명헌 | Backend | [@choimh331](https://github.com/choimh331) | -| 서정찬 | Frontend | [@Jeongchan-Seo](https://github.com/Jeongchan-Seo) | - ---- +## 관련 문서 -## 12. 관련 문서 +### 이 저장소 (docs/) | 문서 | 내용 | |------|------| -| [아키텍처 진화 과정](docs/ARCHITECTURE_COMPARISON.md) | Before → After 아키텍처 상세 비교 | -| [부하 테스트 계획](docs/load-test-plan.md) | 시나리오별 테스트 설계 및 환경 | -| [성능 분석](docs/performance-analysis.md) | 병목 분석 및 최적화 방향 | -| [Gevent DB Thread-Safety](docs/GEVENT_DB_THREAD_SAFETY.md) | OTel + gevent 조합의 DB 이슈 분석 및 해결 | -| [PRD](docs/PRD.md) | 시스템 전체 요구사항 정의서 | - ---- +| [docs/ARCHITECTURE_COMPARISON.md](docs/ARCHITECTURE_COMPARISON.md) | Before → After 아키텍처 상세 비교 및 E2E 시퀀스 다이어그램 | +| [docs/GEVENT_DB_THREAD_SAFETY.md](docs/GEVENT_DB_THREAD_SAFETY.md) | OTel + gevent 조합의 DB thread-safety 이슈 분석 및 해결 | +| [docs/load-test-plan.md](docs/load-test-plan.md) | 시나리오별 부하 테스트 설계 및 환경 | +| [docs/performance-analysis.md](docs/performance-analysis.md) | 병목 분석 및 최적화 방향 | +| [docs/PRD.md](docs/PRD.md) | 시스템 전체 요구사항 정의서 | -## 13. 변경 이력 +### 조직 수준 문서 (.github 저장소) -| 버전 | 날짜 | 변경 내용 | -|------|------|----------| -| 1.0 | 2025-03 | 프로젝트 초기 README | -| 2.0 | 2026-01 | MSA Database 분리, Event-Driven Architecture 적용 | -| 3.0 | 2026-02 | Choreography 패턴 전환, Alert Worker 분리 (Kombu + gevent) | -| 4.0 | 2026-02 | Software Design Document로 전면 개편 | +아키텍처 진화 배경, 알림 설계, 부하 테스트 실측 결과 및 모니터링 스크린샷은 조직 프로필 저장소(`.github`)의 `docs/` 디렉토리에서 관리된다. diff --git a/docs/ARCHITECTURE_COMPARISON.md b/docs/ARCHITECTURE_COMPARISON.md index 5e47b00..e9a888b 100644 --- a/docs/ARCHITECTURE_COMPARISON.md +++ b/docs/ARCHITECTURE_COMPARISON.md @@ -191,22 +191,22 @@ graph TB end subgraph Workers["Event Processors"] - OCR["ocr-worker
• 감지 이벤트 처리
• OCR 수행"] - Alert["alert-worker
• 완료 이벤트 처리
• FCM 발송"] + OCR["ocr-worker
• 감지 이벤트 처리
• OCR 수행
• 도메인 이벤트 발행"] + Alert["alert-worker
• Kombu Consumer (단일 스레드)
• Celery gevent Worker
• FCM Push 병렬 발송"] end subgraph MessageBroker["RabbitMQ"] MQTT["MQTT Plugin"] Queue1[("감지 이벤트 큐")] - Queue2[("알림 이벤트 큐")] + Queue2[("도메인 이벤트
detections.completed")] end Camera -->|"MQTT Publish"| MQTT MQTT --> MQTT_Sub Publisher --> Queue1 Queue1 --> OCR - OCR --> Queue2 - Queue2 --> Alert + OCR -->|"domain_events exchange (topic)"| Queue2 + Queue2 -->|"Choreography: 직접 구독"| Alert Main --> DB1[("default")] Main --> DB2[("vehicles_db")] @@ -414,30 +414,33 @@ sequenceDiagram Edge->>RMQ: MQTT Publish (과속 차량 감지) RMQ-->>Edge: PUBACK (즉시) - RMQ->>Main: 메시지 전달 (subscribe) + RMQ->>Main: 메시지 전달 (MQTT subscribe) Main->>Main: DB 저장 (pending) Main->>RMQ: 감지 이벤트 발행 (AMQP) - RMQ->>OCR: 감지 이벤트 수신 + RMQ->>OCR: 감지 이벤트 수신 (Celery task) OCR->>OCR: 번호판 OCR 처리 OCR->>OCR: DB 업데이트 (completed) - OCR->>RMQ: OCR 완료 이벤트 발행 + OCR->>RMQ: detections.completed 도메인 이벤트 발행
(domain_events exchange, topic type) - RMQ->>Alert: 완료 이벤트 수신 - Alert->>User: FCM Push 알림 + Note over Alert: Choreography 패턴
Alert Worker가 직접 구독 (Main 미개입) + RMQ->>Alert: detections.completed 이벤트 수신
(Kombu Consumer, 단일 스레드) + Alert->>Alert: send_notification.delay() 호출 + Alert->>User: FCM Push 알림
(Celery gevent Worker, 병렬 처리) ``` ### 4.2 Before vs After 비교 -| 문제 영역 | Before | After | -|----------|--------|-------| -| **OCR 처리** | Django 동기 (블로킹) | ocr-worker 비동기 | -| **응답 시간** | 3초+ | < 100ms | -| **IoT 프로토콜** | HTTP (오버헤드) | MQTT (경량, QoS) | -| **메시지 보장** | 없음 | At least once | -| **장애 격리** | 전체 영향 | 컴포넌트 격리 | -| **확장성** | 서버 전체 확장 | Worker별 독립 확장 | -| **데이터베이스** | 단일 DB | 서비스별 4개 DB | +| 문제 영역 | Before | After | Before 실측 | After 실측 | +|----------|--------|-------|-----------|-----------| +| **OCR 처리** | Django 동기 (블로킹) | ocr-worker 비동기 | - | - | +| **응답 시간** | 3초+ | < 100ms | [v1 OCR p95] | [v2 dashboard p95] | +| **IoT 프로토콜** | HTTP (오버헤드) | MQTT (경량, QoS) | - | - | +| **메시지 보장** | 없음 | At least once | - | QoS 1 | +| **장애 격리** | 전체 영향 | 컴포넌트 격리 | - | - | +| **확장성** | 서버 전체 확장 | Worker별 독립 확장 | - | - | +| **데이터베이스** | 단일 DB | 서비스별 4개 DB | - | - | +| **Alert 처리** | Main Service 경유 (Orchestration) | Choreography: Kombu Consumer가 detections.completed 직접 구독 → Celery gevent Worker로 FCM 병렬 발송 | - | - | ### 4.3 핵심 성과 @@ -447,3 +450,27 @@ sequenceDiagram 2. **Edge Device 효율화**: 즉시 응답으로 연속 감지 가능, 데이터 유실 방지 3. **IoT 최적화**: MQTT 프로토콜로 경량화, 메시지 전달 보장, 오프라인 대응 4. **운영 안정성**: 장애 격리, 독립적 확장, 이벤트 보존으로 시스템 복원력 확보 + +### 4.4 실측 성능 데이터 + +> 부하테스트 수행 후 아래 표에 수치를 기록합니다. +> - v1 테스트 스크립트: `depoly-v1/k6/load-test-v1.js` +> - v2 HTTP 테스트 스크립트: `backend/docker/k6/load-test.js` +> - v2 MQTT 테스트 스크립트: `backend/docker/k6/mqtt-load-test.py` + +| 비교 항목 | v1 실측 | v2 실측 | 개선율 | 비고 | +|----------|--------|--------|--------|------| +| 대시보드 읽기 p95 | - | - | - | 동일 3 VUs | +| 목록 조회 p95 | - | - | - | v1:cars / v2:detections | +| 미처리 목록 p95 | - | - | - | v1:unchecked / v2:pending | +| 혼합 워크로드 읽기 p95 | - | - | - | 동일 0→9 VUs | +| 스파이크 15VUs p95 | - | - | - | | +| 스파이크 15VUs 에러율 | - | - | - | | +| 스트레스 50VUs 읽기 p95 | - | - | - | stress_ramp | +| 스트레스 50VUs 읽기 에러율 | - | - | - | stress_ramp | +| 스트레스 50VUs 쓰기 p95 | - | - | - | v1: 동기 OCR / v2: 차량등록 | +| 스트레스 50VUs 혼합 에러율 | - | - | - | stress_mixed | +| OCR 1건 처리시간 | - | - | - | v1: 동기 / v2: 비동기 E2E | +| 최대 안정 TPS | - | - | - | 에러율 < 5% 기준 | + +> **핵심 비교 포인트:** 스트레스 50VUs 쓰기 p95에서 v1(동기 OCR, 3~10초 예상)과 v2(차량등록 POST, <300ms 예상)의 극적인 차이가 OCR 분리 효과를 가장 명확히 보여줍니다. diff --git a/docs/CHOREOGRAPHY_PATTERN.md b/docs/CHOREOGRAPHY_PATTERN.md new file mode 100644 index 0000000..875dd60 --- /dev/null +++ b/docs/CHOREOGRAPHY_PATTERN.md @@ -0,0 +1,335 @@ +# Alert Worker에 Choreography 패턴을 선택한 이유 + +> SpeedCam v2 EDA에서 Alert 알림 흐름을 설계할 때, Orchestration이 아닌 Choreography를 선택한 배경과 구현. + +--- + +## Situation — 알림은 누가 시킬 것인가 + +OCR Worker가 번호판 인식을 마치면 사용자에게 FCM 푸시 알림을 보내야 한다. 이 알림 흐름을 설계할 때 두 가지 선택지가 있었다: + +**선택지 A: Main Service(Django)가 중앙에서 지시하는 Orchestration** + +```mermaid +sequenceDiagram + participant Main as main (Django) + participant OCR as ocr-worker + participant Alert as alert-worker + + Main->>OCR: "이 이미지 OCR 처리해" + OCR-->>Main: "처리 완료, 결과 여기 있어" + Main->>Alert: "이 사용자에게 알림 보내" + Alert-->>Main: "알림 발송 완료" + Note over Main: Main이 모든 흐름을 통제 +``` + +**선택지 B: OCR Worker가 "나 끝났어"라고 외치면 Alert Worker가 알아서 듣는 Choreography** + +```mermaid +sequenceDiagram + participant OCR as ocr-worker + participant Exchange as domain_events
(topic exchange) + participant Alert as alert-worker + + OCR->>Exchange: detections.completed 이벤트 발행 + Note over OCR: "나는 OCR 끝났어.
누가 듣든 상관없어." + Exchange->>Alert: detections.completed 이벤트 전달 + Note over Alert: "OCR 끝났다고?
그럼 내가 알림 보낼게." +``` + +얼핏 보면 Orchestration이 단순해 보인다. Main Service가 전체 흐름을 알고 있으니 디버깅도 쉽고, 순서도 명확하다. 하지만 이 시스템의 맥락을 조금 더 들여다보면 이야기가 달라진다. + +--- + +## Task — 네 가지 기준으로 판단하기 + +결정의 기준은 명확했다: + +1. **커플링**: Main Service가 Alert Worker의 존재를 알아야 하는가? +2. **장애 격리**: OCR이든 Main이든, 한 곳이 죽으면 알림도 죽는가? +3. **확장성**: 새로운 이벤트 소비자(예: 분석 서비스, 로깅)를 추가할 때 기존 코드를 건드려야 하는가? +4. **복잡도**: 구현과 운영의 복잡도는 감당할 수 있는가? + +추가로, Alert Worker의 특성을 고려해야 했다. FCM 푸시는 I/O 바운드 작업이라 `--pool=gevent --concurrency=100`으로 동작한다. 단일 스레드 Kombu Consumer가 이벤트를 수신하고, Celery gevent Worker가 병렬로 FCM을 발송하는 구조다. 이 구조에서 Main Service가 중간에 끼어들 이유가 있는지가 핵심 질문이었다. + +--- + +## Action — Choreography를 선택하다 + +### Orchestration의 문제 + +Orchestration 패턴에서 Main Service는 오케스트라의 지휘자다. 모든 흐름이 Main을 경유한다: + +```mermaid +graph TB + subgraph Orchestration["Orchestration: Main이 지휘자"] + Main["main (Django)
• OCR 요청
• OCR 완료 수신
• Alert 요청
• Alert 완료 수신"] + OCR["ocr-worker"] + Alert["alert-worker"] + + Main -->|"1. OCR 처리 요청"| OCR + OCR -->|"2. 처리 완료 보고"| Main + Main -->|"3. 알림 전송 요청"| Alert + Alert -->|"4. 전송 완료 보고"| Main + end + + style Main fill:#ff9999 + style OCR fill:#87CEEB + style Alert fill:#DDA0DD +``` + +이 구조의 문제점을 하나씩 살펴보면: + +**1. Main Service가 Single Point of Failure가 된다** + +Main Service가 죽으면 OCR은 끝났는데 알림이 나가지 않는다. OCR Worker가 "처리 완료"를 보고해도 Main이 없으면 아무도 Alert Worker에게 일을 시키지 못한다. 이벤트가 큐에 쌓여도 Main이 복구될 때까지 알림은 지연된다. + +**2. Main Service의 책임이 과도해진다** + +Main Service의 본래 역할은 API 서비스와 MQTT 이벤트 수신이다. 여기에 "OCR 완료 후 알림 전송 지시"라는 워크플로우 관리 책임까지 더하면, Main Service가 비즈니스 로직의 God Object가 된다. "알림을 언제, 누구에게 보낼지"는 Alert Worker의 도메인이지, API 서버의 도메인이 아니다. + +**3. 확장할 때마다 Main을 건드려야 한다** + +나중에 "OCR 완료 시 분석 데이터도 수집하자"라는 요구가 오면? Orchestration에서는 Main Service에 새로운 호출을 추가해야 한다: + +```python +# Orchestration: Main Service가 점점 비대해진다 +def on_ocr_completed(result): + alert_worker.send_notification(result) # 기존 + analytics_worker.collect(result) # 추가 1 + audit_worker.log(result) # 추가 2 + # ... Main이 모든 후속 처리를 알아야 한다 +``` + +### Choreography: 각자 자기 일을 한다 + +Choreography에서는 OCR Worker가 "나 끝났어"라고 도메인 이벤트를 발행하고, 관심 있는 서비스들이 알아서 구독한다: + +```mermaid +graph TB + subgraph Choreography["Choreography: 각자 알아서"] + OCR["ocr-worker
• OCR 완료 후
• 도메인 이벤트 발행"] + Exchange["domain_events exchange
(topic type)"] + Alert["alert-worker
• Kombu Consumer
• 이벤트 수신"] + Future1["analytics-worker
(미래)"] + Future2["audit-worker
(미래)"] + + OCR -->|"detections.completed"| Exchange + Exchange -->|"구독"| Alert + Exchange -.->|"구독 (미래)"| Future1 + Exchange -.->|"구독 (미래)"| Future2 + end + + Note["Main Service는 이 흐름에
전혀 관여하지 않는다"] + + style OCR fill:#87CEEB + style Exchange fill:#FFB6C1 + style Alert fill:#DDA0DD + style Future1 fill:#f0f0f0,stroke-dasharray: 5 5 + style Future2 fill:#f0f0f0,stroke-dasharray: 5 5 +``` + +새로운 소비자를 추가할 때 기존 서비스의 코드를 한 줄도 건드리지 않는다. 새 서비스가 `domain_events` exchange에 큐를 바인딩하기만 하면 된다. OCR Worker는 누가 듣고 있는지 모르고, 알 필요도 없다. + +### 실제 구현 + +**RabbitMQ 토폴로지:** + +```mermaid +graph LR + subgraph "OCR Worker (Publisher)" + P["kombu.Producer
publish()"] + end + + subgraph "RabbitMQ" + EX["domain_events
(topic exchange)"] + Q["alert_domain_events
(queue)"] + EX -->|"routing_key:
detections.completed"| Q + end + + subgraph "Alert Worker (Consumer)" + C["Kombu Consumer
(단일 스레드)"] + W["Celery gevent Worker
(concurrency=100)"] + C -->|"send_notification
.delay()"| W + end + + P --> EX + Q --> C + W -->|"FCM Push"| FCM["Firebase"] + + style EX fill:#FFB6C1 + style Q fill:#FFE4B5 +``` + +- **Exchange**: `domain_events` (topic type) — 도메인 이벤트 전용 +- **Routing Key**: `detections.completed` — OCR 완료 이벤트 +- **Queue**: `alert_domain_events` — Alert Worker 전용 큐, exchange에 바인딩 +- **Consumer**: Kombu 기반, 단일 스레드로 이벤트 수신 +- **Worker**: 수신된 이벤트를 `send_notification.delay()`로 Celery 태스크에 위임, gevent pool이 병렬 FCM 발송 + +> **📸 캡처 1**: RabbitMQ Management UI — Exchanges 탭 +> - `http://34.64.183.199:15672` → Exchanges +> - `domain_events` exchange (type: topic) 확인 + +> **📸 캡처 2**: RabbitMQ Management UI — `domain_events` exchange의 Bindings +> - `alert_domain_events` 큐가 `detections.completed` routing key로 바인딩된 상태 + +> **📸 캡처 3**: RabbitMQ Management UI — Queues 탭 +> - `alert_domain_events` 큐의 메시지 수, consumer 수 확인 + +**전체 이벤트 흐름 (시퀀스):** + +```mermaid +sequenceDiagram + participant OCR as ocr-worker + participant RMQ as RabbitMQ + participant KC as Kombu Consumer
(단일 스레드) + participant CW as Celery gevent Worker
(concurrency=100) + participant FCM as Firebase + + OCR->>OCR: 번호판 OCR 완료 + OCR->>OCR: DB 업데이트 (completed) + OCR->>RMQ: publish(exchange='domain_events',
routing_key='detections.completed',
body={detection_id, plate_number, ...}) + + Note over OCR: OCR Worker의 역할은 여기서 끝.
누가 이 이벤트를 소비하는지 모른다. + + RMQ->>KC: alert_domain_events 큐에서 메시지 전달 + KC->>KC: 이벤트 파싱 및 검증 + KC->>CW: send_notification.delay(detection_id) + + Note over KC: Consumer는 Celery에 위임하고
즉시 다음 이벤트 수신 준비 + + CW->>FCM: FCM Push 전송 (greenlet A) + CW->>FCM: FCM Push 전송 (greenlet B) + CW->>FCM: FCM Push 전송 (greenlet C) + Note over CW: gevent pool에서 병렬 처리 +``` + +### 왜 Orchestration이 아닌가 — 정리 + +| 기준 | Orchestration | Choreography | +|------|---------------|--------------| +| **커플링** | Main이 OCR, Alert 모두를 알아야 함 | OCR은 이벤트만 발행, Alert은 이벤트만 구독 | +| **장애 격리** | Main 장애 시 알림 흐름 전체 중단 | Main 장애와 무관하게 알림 정상 동작 | +| **확장성** | 새 소비자 추가 시 Main 코드 수정 필요 | 새 큐를 exchange에 바인딩하면 끝 | +| **책임 분리** | Main이 워크플로우 관리자 역할 병행 | 각 서비스가 자기 도메인에만 집중 | +| **디버깅** | Main에서 전체 흐름을 추적 가능 (**장점**) | 이벤트 흐름이 암묵적, 추적이 어려움 | +| **일관성** | 중앙 제어로 순서 보장 용이 (**장점**) | 이벤트 순서 보장 어려움, 멱등성 필요 | +| **가시성** | 코드만 보면 흐름이 보임 (**장점**) | exchange/queue 바인딩까지 확인해야 함 | + +Orchestration의 장점은 분명히 있다. 코드를 읽는 것만으로 "OCR 끝나면 알림 간다"는 흐름이 보인다. Choreography에서는 OCR Worker 코드만 보면 "이벤트를 발행한다"는 것만 알 수 있고, 누가 그 이벤트를 소비하는지는 RabbitMQ Management UI나 인프라 설정을 봐야 한다. + +그럼에도 Choreography를 선택한 이유는, **알림은 Main Service의 도메인이 아니기 때문이다.** Main Service는 API 서비스와 MQTT 이벤트 수신이라는 본래 역할에 집중해야 한다. "OCR이 끝나면 알림을 보내라"는 워크플로우 지식을 Main에 넣는 순간, Main은 시스템의 모든 후속 처리를 알아야 하는 God Object가 된다. + +Martin Fowler의 표현을 빌리면: Choreography에서 각 서비스는 자기 역할만 수행하고, 시스템 전체의 행동은 서비스들 간의 상호작용에서 **창발(emergent)**한다. + +--- + +## Result — 독립적으로 동작하는 Alert Worker + +### 장애 격리 검증 + +Choreography의 가장 큰 이점은 장애 격리에서 드러난다: + +```mermaid +graph TB + subgraph "시나리오 1: Main Service 장애" + OCR1["ocr-worker ✅"] -->|"detections.completed"| EX1["domain_events ✅"] + EX1 --> Alert1["alert-worker ✅"] + Main1["main ❌ 장애"] -.->|"관여 안 함"| EX1 + + Note1["Main이 죽어도 알림은 정상 발송"] + end + + subgraph "시나리오 2: Alert Worker 장애" + OCR2["ocr-worker ✅"] -->|"detections.completed"| EX2["domain_events ✅"] + EX2 --> Q2["alert_domain_events
큐에 메시지 보존"] + Alert2["alert-worker ❌ 장애"] + + Note2["Alert 복구 시 큐의 메시지 자동 처리"] + end + + style Main1 fill:#ff6b6b,color:#fff + style Alert2 fill:#ff6b6b,color:#fff + style Alert1 fill:#90EE90 + style OCR1 fill:#90EE90 + style OCR2 fill:#90EE90 +``` + +- **Main Service 장애**: OCR Worker → domain_events exchange → Alert Worker 경로에 Main이 없으므로, 알림 흐름에 영향 없음 +- **Alert Worker 장애**: 이벤트는 `alert_domain_events` 큐에 보존되고, Worker 복구 시 자동으로 소비 +- **OCR Worker 장애**: Alert Worker는 이벤트가 안 오니 idle 상태일 뿐, 장애가 전파되지 않음 + +### 부하 테스트 결과 + +burst 600건의 감지 메시지를 발행한 부하 테스트에서: +- OCR Worker가 처리를 완료하면 `detections.completed` 이벤트 자동 발행 +- Alert Worker의 Kombu Consumer가 이벤트를 수신하고 `send_notification.delay()` 호출 +- Celery gevent Worker(concurrency=100)가 병렬로 FCM 발송 +- **356건의 알림이 자동 발송됨** — Main Service는 이 과정에 전혀 관여하지 않았음 + +> **📸 캡처 4**: Grafana 대시보드 — Alert Worker 메트릭 +> - `http://34.47.70.132:3000` → Alert Worker 대시보드 +> - 부하 테스트 시점의 이벤트 수신 및 알림 발송 그래프 + +### 확장성: 미래의 소비자 추가 + +```mermaid +graph LR + OCR["ocr-worker"] -->|"detections.completed"| EX["domain_events
(topic exchange)"] + + EX --> Q1["alert_domain_events"] + EX -.-> Q2["analytics_domain_events
(미래)"] + EX -.-> Q3["audit_domain_events
(미래)"] + + Q1 --> A1["alert-worker"] + Q2 -.-> A2["analytics-worker"] + Q3 -.-> A3["audit-worker"] + + Note["OCR Worker 코드 변경: 0줄
Alert Worker 코드 변경: 0줄
Main Service 코드 변경: 0줄"] + + style Q2 fill:#f0f0f0,stroke-dasharray: 5 5 + style Q3 fill:#f0f0f0,stroke-dasharray: 5 5 + style A2 fill:#f0f0f0,stroke-dasharray: 5 5 + style A3 fill:#f0f0f0,stroke-dasharray: 5 5 +``` + +새로운 서비스가 `detections.completed` 이벤트를 소비하고 싶다면: +1. 새 큐를 생성하고 `domain_events` exchange에 바인딩 +2. 새 서비스에서 해당 큐를 구독 +3. **기존 서비스 코드 변경: 0줄** + +이것이 Choreography의 본질적 가치다. 시스템은 **새로운 참여자의 등장에 열려 있고, 기존 참여자의 변경에 닫혀 있다.** Open-Closed Principle이 서비스 간 통신 수준에서 실현된다. + +### 솔직한 Trade-off + +Choreography가 만능은 아니다. 운영하면서 느낀 단점도 있다: + +| 단점 | 영향 | 대응 | +|------|------|------| +| **흐름 추적 어려움** | "이 이벤트를 누가 소비하지?" → 코드만으로는 안 보임 | RabbitMQ Management UI에서 exchange binding 확인, Jaeger 분산 트레이싱 | +| **디버깅 복잡도** | 이벤트 발행은 됐는데 알림이 안 간다면? 원인이 여러 곳에 분산 | Loki 로그 + Jaeger 트레이스 + RabbitMQ 큐 모니터링 조합 | +| **암묵적 의존성** | exchange/queue 바인딩이 코드에 드러나지 않음 | 인프라 문서화, ARCHITECTURE_COMPARISON.md에 전체 흐름 기록 | +| **이벤트 순서** | 여러 소비자가 이벤트를 받는 순서를 보장할 수 없음 | 각 소비자가 멱등하게 동작하도록 설계 | + +이 trade-off를 감수할 수 있었던 이유는, 모니터링 스택(Prometheus + Grafana + Loki + Jaeger)이 이미 구축되어 있었기 때문이다. 흐름의 가시성이 코드에서 인프라 모니터링으로 이동하는 것이지, 사라지는 것은 아니다. + +--- + +## References + +### 공식 문서 + +- [Martin Fowler — Microservices: Choreography vs Orchestration](https://martinfowler.com/bliki/ServiceOrientedAmbiguity.html) +- [RabbitMQ — Topic Exchange](https://www.rabbitmq.com/tutorials/tutorial-five-python) +- [Kombu — Consumer Guide](https://docs.celeryq.dev/projects/kombu/en/stable/userguide/consumers.html) + +### 기술 블로그 + +- [Chris Richardson — Pattern: Choreography](https://microservices.io/patterns/data/choreography.html) +- [AWS — Choreography vs. Orchestration in the Land of Serverless](https://aws.amazon.com/blogs/compute/operating-lambda-understanding-event-driven-architecture-part-3/) + +### 관련 프로젝트 문서 + +- [ARCHITECTURE_COMPARISON.md](./ARCHITECTURE_COMPARISON.md) — 전체 아키텍처 진화 과정 +- [GEVENT_DB_THREAD_SAFETY.md](./GEVENT_DB_THREAD_SAFETY.md) — Alert Worker의 gevent pool + Django ORM 이슈 diff --git a/docs/MQTT_IOT_PROTOCOL.md b/docs/MQTT_IOT_PROTOCOL.md new file mode 100644 index 0000000..b190ec7 --- /dev/null +++ b/docs/MQTT_IOT_PROTOCOL.md @@ -0,0 +1,316 @@ +# Edge Device 통신에 HTTP 대신 MQTT를 선택한 이유 + +> 과속 감지 카메라(Raspberry Pi)와 백엔드 사이의 통신 프로토콜을 HTTP POST에서 MQTT로 전환한 아키텍처 결정의 배경과 결과. + +> **Note (2025-02)**: 현재 시스템은 RabbitMQ MQTT Plugin(포트 1883)을 통해 Edge Device → Backend 통신을 처리한다. IoT 경계는 MQTT, 서비스 간 이벤트는 AMQP라는 **프로토콜 분리 원칙**이 적용되어 있다. + +--- + +## Situation — v1의 HTTP 기반 통신이 만든 병목 + +SpeedCam v1에서 Raspberry Pi 카메라는 과속 차량을 감지하면 HTTP POST로 Django 서버에 이미지를 전송했다. 간단하고 직관적인 구조였다. + +```mermaid +sequenceDiagram + participant Pi as Raspberry Pi + participant DJ as Django (Gunicorn) + participant OCR as OCR Processing + + Pi->>DJ: HTTP POST /api/v1/detections/ (이미지 포함) + DJ->>OCR: OCR 처리 시작 + Note over Pi: ⏳ 응답 대기 중...
카메라 모니터링 중단 + OCR-->>DJ: OCR 결과 + DJ-->>Pi: HTTP 200 (평균 20.45초) + Note over Pi: 다시 모니터링 시작 +``` + +문제는 세 가지였다: + +**1. Edge Device가 서버 응답을 기다리며 멈춘다.** + +HTTP는 요청-응답 모델이다. Pi가 POST를 보내면 서버가 응답할 때까지 **평균 20.45초** 동안 블로킹된다. 그 사이 지나가는 과속 차량은 놓친다. 카메라가 "감지 장치"가 아니라 "업로드 장치"가 되어버린 것이다. + +**2. 네트워크 불안정에 취약하다.** + +과속 카메라는 터널, 고속도로 진입로, 도심 외곽 같은 곳에 설치된다. HTTP는 요청마다 TCP 3-way handshake가 필요하고, 연결이 끊기면 재전송 메커니즘이 애플리케이션 레벨에서 직접 구현해야 한다. 네트워크가 불안정할 때 데이터 유실 위험이 있었다. + +**3. 프로토콜 오버헤드가 크다.** + +카메라가 보내는 메시지는 실제로 작다 — camera_id, location, speed, GCS image URI 정도의 JSON이다. 그런데 HTTP는 요청마다 헤더(Content-Type, Authorization, User-Agent, ...)와 TCP 핸드셰이크를 반복한다. 20대 카메라가 각각 분당 수 건씩 보내는 구조에서 이 오버헤드는 무의미한 낭비다. + +``` +# v1: HTTP POST 메시지 (실제 페이로드 대비 오버헤드 큼) +POST /api/v1/detections/ HTTP/1.1 +Host: speedcam-app:8000 +Content-Type: application/json +Authorization: Bearer eyJ... +Content-Length: 234 +Connection: keep-alive + +{"camera_id": "CAM-001", "location": "서울시 강남구 테헤란로", ...} +``` + +--- + +## Task — 프로토콜 수준에서 문제를 해결해야 했다 + +HTTP 위에서 비동기 처리를 구현할 수도 있었다 — 예를 들어 서버가 즉시 202 Accepted를 반환하고 백그라운드로 처리하는 방식. 하지만 이것은 문제의 일부만 해결한다. 네트워크 불안정과 프로토콜 오버헤드는 그대로 남는다. + +내가 필요한 프로토콜의 조건은 명확했다: + +1. **Non-blocking publish**: 카메라가 메시지를 보내고 **즉시** 모니터링으로 돌아갈 수 있어야 한다 +2. **QoS 보장**: 과속 위반 데이터는 **절대 유실되면 안 된다** — 프로토콜 레벨에서 전달 보장 필요 +3. **Persistent connection**: 연결 한 번으로 메시지를 계속 발행 — 핸드셰이크 반복 제거 +4. **경량 프로토콜**: 임베디드 디바이스(Raspberry Pi)에서도 부담 없는 오버헤드 +5. **기존 인프라 호환**: 이미 RabbitMQ를 메시지 브로커로 쓰고 있으므로 별도 브로커 추가 없이 통합 + +--- + +## Action — MQTT + RabbitMQ MQTT Plugin 도입 + +### 왜 MQTT인가 + +MQTT(Message Queuing Telemetry Transport)는 IoT 환경을 위해 설계된 경량 pub/sub 프로토콜이다. IBM이 1999년 석유 파이프라인 모니터링을 위해 만들었고, 저대역폭/불안정한 네트워크에서 센서 데이터를 전송하는 것이 원래 목적이다. 우리의 "야외 카메라 → 클라우드 서버" 시나리오와 정확히 일치한다. + +| 특성 | HTTP | MQTT | +|------|------|------| +| 통신 모델 | 요청-응답 (동기) | Pub/Sub (비동기) | +| 연결 방식 | 요청마다 새 연결 (또는 Keep-Alive) | 한 번 연결, 계속 유지 | +| QoS | 없음 (애플리케이션 레벨 구현) | 프로토콜 내장 (0, 1, 2) | +| 헤더 오버헤드 | 수백 바이트 | 2~5 바이트 (고정 헤더) | +| 오프라인 버퍼링 | 없음 | 브로커가 메시지 보관 | +| 양방향 통신 | 별도 구현 필요 | 기본 지원 (토픽 구독) | + +### 아키텍처 설계 + +RabbitMQ MQTT Plugin은 MQTT 메시지를 내부적으로 AMQP로 변환한다. 이 덕분에 **새로운 브로커를 추가하지 않고** 기존 RabbitMQ 인프라 위에 MQTT 엔드포인트를 열 수 있었다. + +```mermaid +graph LR + subgraph "Edge Devices (IoT 경계)" + CAM1[Raspberry Pi
CAM-001] + CAM2[Raspberry Pi
CAM-002] + CAMn[Raspberry Pi
CAM-020] + end + + subgraph "RabbitMQ" + MQTT_PORT["MQTT Plugin
:1883"] + AMQP_PORT["AMQP
:5672"] + MQTT_PORT -->|"MQTT → AMQP
내부 변환"| AMQP_PORT + end + + subgraph "Backend (서비스 경계)" + SUB["Django
MQTT Subscriber"] + CELERY["Celery Workers
(AMQP)"] + end + + CAM1 -->|"MQTT QoS 1"| MQTT_PORT + CAM2 -->|"MQTT QoS 1"| MQTT_PORT + CAMn -->|"MQTT QoS 1"| MQTT_PORT + + AMQP_PORT --> SUB + SUB -->|"AMQP 이벤트 발행"| CELERY + + style MQTT_PORT fill:#00b894,color:#fff + style AMQP_PORT fill:#0984e3,color:#fff +``` + +**프로토콜 분리 원칙**: IoT 디바이스는 MQTT로만 통신하고, 백엔드 서비스 간 이벤트는 AMQP를 사용한다. 각 프로토콜이 자기 영역에서 최적으로 동작한다. + +### QoS 1 선택 — "at least once delivery" + +MQTT에는 세 단계의 QoS가 있다: + +| QoS | 의미 | 동작 | 적합한 상황 | +|-----|------|------|-------------| +| 0 | At most once | Fire and forget | 센서 온도 등 유실 허용 | +| **1** | **At least once** | **PUBACK 확인** | **과속 위반 — 유실 불가** | +| 2 | Exactly once | 4-way handshake | 결제 등 중복 불가 | + +과속 위반 데이터는 법적 근거가 되므로 **절대 유실되면 안 된다.** QoS 1은 브로커가 PUBACK을 돌려줄 때까지 클라이언트가 메시지를 보관하므로 네트워크 순단에도 데이터가 보존된다. 중복 수신 가능성은 있지만, 서버 측에서 idempotent하게 처리하면 된다(Detection 레코드의 camera_id + detected_at 조합으로 중복 감지). + +### 메시지 포맷과 토픽 설계 + +```python +# Edge Device가 발행하는 MQTT 메시지 (Topic: detections/new, QoS 1) +{ + "camera_id": "CAM-001", + "location": "서울시 강남구 테헤란로", + "detected_speed": 85.3, + "speed_limit": 60.0, + "detected_at": "2025-02-15T14:30:22+09:00", + "image_gcs_uri": "gs://speedcam-bucket/detections/real-plate-01.jpg" +} +``` + +이미지 자체를 MQTT로 보내지 않는다는 점이 중요하다. 이미지는 GCS(Google Cloud Storage)에 먼저 업로드하고, MQTT 메시지에는 GCS URI만 포함한다. MQTT 메시지를 수십 바이트 수준으로 유지하면서 이미지는 GCS의 안정적인 인프라를 활용한다. + +### Django MQTT Subscriber 구현 + +```python +# core/mqtt/subscriber.py +class MQTTSubscriber: + """ + Flow: + 1. Raspberry Pi -> MQTT Publish (detections/new) + 2. RabbitMQ MQTT Plugin -> 내부 변환 + 3. Django MQTT Subscriber -> 메시지 수신 + 4. Detection 생성 (pending) -> OCR Task 발행 (AMQP) + """ + + def __init__(self): + self.client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + protocol=mqtt.MQTTv311, + client_id=f"django-main-{os.getpid()}", + ) + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + self.client.on_disconnect = self.on_disconnect + self.client.username_pw_set(username, password) + + def on_connect(self, client, userdata, flags, reason_code, properties): + """연결 시 토픽 구독 — reconnect에도 자동 재구독""" + client.subscribe("detections/new", qos=1) + + def on_message(self, client, userdata, msg): + """메시지 수신 → DB 레코드 생성 → OCR Task 발행""" + payload = json.loads(msg.payload.decode()) + + close_old_connections() # 장기 실행 스레드에서 stale DB 연결 방지 + + detection = Detection.objects.using("detections_db").create( + camera_id=payload["camera_id"], + location=payload["location"], + detected_speed=payload["detected_speed"], + speed_limit=payload.get("speed_limit", 60.0), + detected_at=payload.get("detected_at"), + image_gcs_uri=payload["image_gcs_uri"], + status="pending", + ) + + # AMQP를 통해 OCR Worker에 태스크 발행 + process_ocr.apply_async( + args=[detection.id], + kwargs={"gcs_uri": payload["image_gcs_uri"]}, + queue="ocr_queue", + ) +``` + +`on_connect`에서 구독하는 패턴이 핵심이다. MQTT 클라이언트가 네트워크 단절 후 재연결되면 `on_connect`가 다시 호출되므로, **자동 재구독**이 보장된다. HTTP 기반이었다면 이런 복원력을 직접 구현해야 했다. + +### v1 → v2 통신 흐름 비교 + +```mermaid +sequenceDiagram + participant Pi as Raspberry Pi + participant MQ as RabbitMQ
(MQTT :1883) + participant SUB as Django
MQTT Subscriber + participant DB as MySQL + + Note over Pi,DB: v2: MQTT 기반 (Non-blocking) + + Pi->>MQ: MQTT PUBLISH (QoS 1)
detections/new + MQ-->>Pi: PUBACK (< 1ms) + Note over Pi: 즉시 모니터링 복귀 + + MQ->>SUB: 메시지 전달 + SUB->>DB: Detection 생성 (pending) + SUB->>MQ: OCR Task 발행 (AMQP) + Note over SUB: 비동기 파이프라인 시작 +``` + +카메라 입장에서 PUBACK을 받는 데 걸리는 시간은 **1ms 미만**이다. 20.45초를 기다리던 것과 비교하면, 카메라는 사실상 블로킹 없이 동작한다. + +--- + +## Result — Before & After + +### MQTT 부하 테스트 결과 + +실제 운영 패턴을 시뮬레이션하는 부하 테스트를 설계하고 실행했다. 세 가지 시나리오로 MQTT 파이프라인의 안정성을 검증했다: + +| 시나리오 | 카메라 수 | 발행 속도 | 총 메시지 | 발행 성공 | 파이프라인 완료 | DLQ 메시지 | +|----------|-----------|-----------|-----------|-----------|-----------------|------------| +| normal (0.33 msg/s) | 20대 | 1건/분 | 40 | 100% | 100% | 0 | +| rush_hour (1.67 msg/s) | 20대 | 5건/분 | 200 | 100% | 100% | 0 | +| burst (10 msg/s) | 10대 | 1건/초 | 600 | 100% | 100% (157초) | 0 | + +> **📸 캡처 1**: Grafana - MQTT 부하테스트 burst 시나리오 대시보드 +> - `http://34.47.70.132:3000` → SpeedCam Dashboard +> - 발행 속도 10 msg/s 구간에서 OCR 큐 깊이 변화와 완료율 추이 + +> **📸 캡처 2**: RabbitMQ Management - MQTT 연결 및 큐 상태 +> - `http://34.64.183.199:15672` → Connections 탭 (MQTT 연결 목록) +> - Queues 탭 (ocr_queue, fcm_queue, dlq_queue 깊이) + +burst 시나리오가 특히 의미 있다. 60초 동안 600건의 메시지가 밀려들어도 **단 한 건의 유실 없이** 모든 메시지가 파이프라인을 완주했다. DLQ(Dead Letter Queue) 메시지 0건은 재시도 실패도 없었다는 뜻이다. + +### Before & After 비교 + +| | Before (v1: HTTP POST) | After (v2: MQTT) | +|---|---|---| +| Edge → Server 지연 | 평균 20.45초 (응답 대기) | < 1ms (PUBACK) | +| 카메라 상태 | 업로드 중 모니터링 중단 | 즉시 모니터링 복귀 | +| 네트워크 복원력 | 없음 (요청 실패 시 유실) | QoS 1 — 브로커가 전달 보장 | +| 연결 방식 | 요청마다 TCP 핸드셰이크 | Persistent connection (1회 연결) | +| 프로토콜 오버헤드 | 수백 바이트 HTTP 헤더 | 2~5 바이트 고정 헤더 | +| 오프라인 내성 | 없음 | 브로커가 메시지 보관 (QoS 1) | +| 추가 인프라 | — | 없음 (RabbitMQ MQTT Plugin 활성화만) | +| burst 처리 (600건/60초) | 서버 과부하 위험 | 100% 전달, 0 유실 | + +> **📸 캡처 3**: Grafana - v1 HTTP 기반 Detection 생성 시간 +> - `http://34.47.70.132:3000` → HTTP API Response Time 패널 +> - 평균 20.45초 응답 시간이 보이는 구간 + +> **📸 캡처 4**: Grafana - v2 MQTT 기반 Detection 생성 시간 +> - `http://34.47.70.132:3000` → MQTT Pipeline Latency 패널 +> - MQTT PUBLISH → Detection(pending) 생성까지의 지연 시간 + +### 프로토콜 분리가 만든 구조적 이점 + +MQTT 도입으로 얻은 것은 단순히 속도 개선만이 아니다. **IoT 수집과 HTTP API의 관심사가 분리**되었다. + +```mermaid +graph TB + subgraph "IoT 수집 (MQTT)" + CAM[Edge Devices] -->|"MQTT :1883"| MQ_MQTT[RabbitMQ MQTT Plugin] + MQ_MQTT --> SUB[MQTT Subscriber] + end + + subgraph "API 서비스 (HTTP)" + CLIENT[Web/Mobile Client] -->|"HTTP :8000"| API[Django REST API] + end + + subgraph "비동기 처리 (AMQP)" + SUB -->|"OCR Task"| OCR[OCR Worker] + OCR -->|"Alert Event"| ALERT[Alert Worker] + end + + DB[(MySQL)] + SUB --> DB + API --> DB + + style MQ_MQTT fill:#00b894,color:#fff + style API fill:#0984e3,color:#fff +``` + +MQTT Subscriber가 다운되어도 HTTP API는 영향 없이 동작한다. 반대로 API에 트래픽이 몰려도 MQTT 메시지 수신은 독립적으로 처리된다. 각 경로가 서로의 장애에 격리된 구조다. + +--- + +## References + +### 공식 문서 + +- [MQTT v3.1.1 Specification (OASIS Standard)](http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html) +- [RabbitMQ MQTT Plugin](https://www.rabbitmq.com/docs/mqtt) +- [Eclipse Paho MQTT Python Client](https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html) +- [Django Management Commands](https://docs.djangoproject.com/en/5.1/howto/custom-management-commands/) + +### 기술 참고 + +- [MQTT vs HTTP for IoT — HiveMQ](https://www.hivemq.com/blog/mqtt-vs-http-protocols-in-iot-iiot/) +- [MQTT QoS Levels Explained — HiveMQ](https://www.hivemq.com/blog/mqtt-essentials-part-6-mqtt-quality-of-service-levels/) +- [RabbitMQ as an MQTT Broker — CloudAMQP](https://www.cloudamqp.com/blog/rabbitmq-mqtt.html) +- [Designing IoT Architectures with MQTT — AWS IoT](https://docs.aws.amazon.com/iot/latest/developerguide/mqtt.html) diff --git a/docs/PERFORMANCE_TEST_GUIDE.md b/docs/PERFORMANCE_TEST_GUIDE.md deleted file mode 100644 index 12d20ff..0000000 --- a/docs/PERFORMANCE_TEST_GUIDE.md +++ /dev/null @@ -1,864 +0,0 @@ -# 성능 테스트 가이드 - -SpeedCam IoT 백엔드의 성능, 안정성, 파이프라인 처리 능력을 검증하기 위한 종합 가이드입니다. HTTP API 부하 테스트(k6)와 IoT 파이프라인 부하 테스트(MQTT)를 다룹니다. - ---- - -## 목차 - -1. [사전 준비](#1-사전-준비) -2. [모니터링 대시보드](#2-모니터링-대시보드) -3. [HTTP API 부하 테스트 (k6)](#3-http-api-부하-테스트-k6) -4. [IoT 파이프라인 부하 테스트 (MQTT)](#4-iot-파이프라인-부하-테스트-mqtt) -5. [End-to-End 검증 체크리스트](#5-end-to-end-검증-체크리스트) -6. [트러블슈팅](#6-트러블슈팅) -7. [정리 및 종료](#7-정리-및-종료) - ---- - -## 1. 사전 준비 - -### 1.1 필요 도구 - -| 도구 | 용도 | 설치 방법 | -|------|------|----------| -| Docker | 컨테이너 실행 | https://docs.docker.com/get-docker/ | -| Docker Compose | 다중 컨테이너 관리 | Docker Desktop 포함 | -| Python 3.x | MQTT 파이프라인 테스트 | 기본 설치됨 | -| paho-mqtt | MQTT 클라이언트 | `pip install paho-mqtt` | -| curl | API 요청 테스트 | 기본 설치됨 | - -### 1.2 환경 시작 - -**중요**: `docker-compose.yml`이 `speedcam-network`를 생성하고, `docker-compose.monitoring.yml`은 이를 `external: true`로 참조합니다. 반드시 순서대로 또는 `-f` 플래그로 함께 시작하세요. - -```bash -# 방법 1: 앱 + 모니터링 함께 시작 (권장) -cd docker -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d -``` - -```bash -# 방법 2: 순차 시작 -cd docker -docker compose -f docker-compose.yml up -d -docker compose -f docker-compose.monitoring.yml up -d -``` - -### 1.3 macOS 참고사항 - -- **cAdvisor는 Linux 전용**: `docker-compose.monitoring.yml`에 `profiles: [linux]`가 설정되어 있으므로 macOS에서는 자동 제외됩니다 -- **Linux에서 cAdvisor 포함**: - ```bash - docker compose -f docker-compose.yml -f docker-compose.monitoring.yml --profile linux up -d - ``` -- **권장 Docker Desktop 메모리**: 8GB 이상 - -### 1.4 서비스 상태 확인 - -```bash -# 전체 컨테이너 상태 확인 -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml ps -``` - -**예상 상태**: 모든 컨테이너가 `Up` 상태 - -```bash -# Prometheus 타겟 상태 확인 -curl -s http://localhost:9090/api/v1/targets | python3 -c " -import json, sys -data = json.load(sys.stdin) -print('Prometheus Scrape Targets:') -for t in data['data']['activeTargets']: - status = '✓ UP' if t['health'] == 'up' else '✗ DOWN' - print(f\" {t['labels']['job']:20s} {t['health']:5s} {t['labels']['instance']}\") -" -``` - -**예상 출력**: -``` -Prometheus Scrape Targets: - django up main:8000 - otel-collector up otel-collector:8889 - rabbitmq up rabbitmq:15692 - mysql up mysqld-exporter:9104 - celery up celery-exporter:9808 - cadvisor up cadvisor:8080 (Linux only) -``` - ---- - -## 2. 모니터링 대시보드 - -### 2.1 접속 정보 - -| 서비스 | URL | 인증 | 용도 | -|--------|-----|------|------| -| **Grafana** | http://localhost:3000 | admin / admin | 통합 대시보드 (메트릭, 로그, 트레이스) | -| **Prometheus** | http://localhost:9090 | 없음 | 메트릭 저장소 및 PromQL 쿼리 | -| **Jaeger** | http://localhost:16686 | 없음 | 분산 트레이싱 UI | -| **RabbitMQ** | http://localhost:15672 | sa / 1234 | 큐 모니터링 | -| **Flower** | http://localhost:5555 | 없음 | Celery 태스크 모니터링 | - -### 2.2 Grafana 대시보드 Import - -시작 시 자동으로 대시보드가 프로비저닝되지만, 추가 대시보드는 수동 import: - -1. Grafana 접속: http://localhost:3000 -2. Dashboards → New → Import -3. Dashboard ID 입력: - -| 대시보드 | ID | 데이터소스 | 용도 | -|---------|-----|-----------|------| -| Django Prometheus | 17658 | Prometheus | HTTP 요청, 응답시간, 에러율 | -| Celery Monitoring | 17509 | Prometheus | 태스크 성공/실패, 큐 깊이 | -| RabbitMQ Overview | 10991 | Prometheus | 메시지 rate, 큐 깊이 | -| MySQL Overview | 14057 | Prometheus | 쿼리 수, 커넥션, 슬로우 쿼리 | -| K6 Load Testing | 19665 | Prometheus | k6 부하 테스트 결과 (실시간) | - -### 2.3 주요 메트릭 보기 - -**Django HTTP 메트릭** (자동 수집): -``` -http://localhost:3000/d/ -``` - -**Jaeger 트레이스** (요청 플로우 추적): -``` -http://localhost:16686 → Services → speedcam-api → 최근 트레이스 보기 -``` - -**Loki 로그** (구조화된 로그): -``` -Grafana → Explore → Data source: Loki -쿼리: {service="main"} -``` - ---- - -## 3. HTTP API 부하 테스트 (k6) - -### 3.1 테스트 대상 - -REST API 엔드포인트 검증 (IoT 파이프라인 제외): - -| 엔드포인트 | 메서드 | 용도 | -|-----------|--------|------| -| `/health/` | GET | 헬스 체크 | -| `/api/v1/vehicles/` | GET, POST, PUT, DELETE | 차량 CRUD | -| `/api/v1/detections/` | GET | 검출 목록 조회 | -| `/api/v1/notifications/` | GET | 알림 목록 조회 | - -### 3.2 설치 - -paho-mqtt는 MQTT 테스트에만 필요합니다. k6 테스트는 Docker 컨테이너에서 실행되므로 호스트 설치 불필요합니다. - -### 3.3 실행 방법 - -```bash -cd docker - -# 기본 실행: Prometheus에 결과 기록 -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml \ - run k6 run --out experimental-prometheus-rw /scripts/load-test.js -``` - -**선택 사항**: 환경 변수 오버라이드 - -```bash -# 커스텀 대상 서버 지정 -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml \ - run -e MAIN_SERVICE_URL=http://localhost:8000 k6 \ - run --out experimental-prometheus-rw /scripts/load-test.js -``` - -### 3.4 시나리오별 테스트 - -`load-test.js`에 3가지 시나리오가 정의되어 있습니다. 각 시나리오는 startTime이 다르므로 한 번의 실행으로 모두 테스트됩니다. - -| 시나리오 | VU 범위 | 시간 | 시작 시간 | 용도 | 기대 결과 | -|----------|---------|------|----------|------|----------| -| **smoke** | 1 | 10초 | 0s | 기본 동작 확인 | 에러 0%, 응답 <100ms | -| **average_load** | 0→10→0 | ~100초 | 15s | 평균 부하 검증 | p95 <500ms, 에러 <1% | -| **spike** | 0→30→0 | ~25초 | 120s | 스파이크 처리 능력 | p99 <1000ms, 에러 <5% | - -**총 실행 시간**: ~2분 30초 - -### 3.5 실행 중 모니터링 - -실시간으로 다른 터미널에서 메트릭 확인: - -```bash -# Prometheus UI에서 확인 -open http://localhost:9090/graph -# 쿼리: rate(k6_http_reqs_total[1m]) -``` - -```bash -# Grafana K6 대시보드 (ID: 19665) 보기 -open http://localhost:3000/d/K6-dashboard -``` - -### 3.6 결과 해석 - -k6 실행 완료 후 stdout에 요약이 표시됩니다: - -``` - checks.........................: 100.00% ✓ 5000 ✗ 0 - data_received..................: 1.2 MB ✓ - data_sent.......................: 850 kB ✓ - http_req_blocked...............: avg=1.2ms min=100µs max=50ms p(90)=2.1ms p(95)=3.5ms - http_req_connecting............: avg=0.8ms min=0µs max=40ms p(90)=1.5ms p(95)=2.2ms - http_req_duration..............: avg=125ms min=50ms max=2s p(90)=350ms p(95)=450ms - http_req_failed................: 0.00% ✓ 0 ✗ 5000 - http_req_receiving.............: avg=2.5ms min=0.5ms max=20ms p(90)=4ms p(95)=5ms - http_req_sending...............: avg=0.5ms min=0.1ms max=5ms p(90)=1ms p(95)=1ms - http_req_tls_handshaking.......: avg=0ms min=0µs max=0s p(90)=0s p(95)=0s - http_req_waiting...............: avg=122ms min=48ms max=1.9s p(90)=348ms p(95)=448ms - http_reqs.......................: 5000 199.31/s - iteration_duration.............: avg=2.5s min=2s max=30s p(90)=2.8s p(95)=3.1s - iterations......................: 5000 199.31/s -``` - -**주요 메트릭**: -- `checks`: 테스트 검증 통과율 (100%이어야 함) -- `http_req_duration` (p95): 95% 요청의 응답시간 (목표 <500ms) -- `http_req_failed`: 실패율 (0%이어야 함) -- `http_reqs`: 초당 처리한 요청 수 (RPS) - -### 3.7 맞춤형 시나리오 작성 - -`load-test.js`를 수정하여 커스텀 시나리오를 추가할 수 있습니다. 자세한 내용은 [k6 공식 문서](https://k6.io/docs/get-started/running-k6/)를 참고하세요. - ---- - -## 4. IoT 파이프라인 부하 테스트 (MQTT) - -### 4.1 테스트 대상 - -실제 IoT 카메라 동작을 시뮬레이션하여 전체 파이프라인을 검증합니다: - -``` -MQTT 메시지 발행 (Raspberry Pi 시뮬) - ↓ -RabbitMQ 큐에 저장 - ↓ -Detection 생성 (pending) - ↓ -OCR Worker (이미지 처리) - ↓ -Alert Worker (FCM 알림) - ↓ -완료 (completed) -``` - -### 4.2 사전 준비 - -**호스트에서 실행하는 경우**: -```bash -pip install paho-mqtt -``` - -### 4.3 실행 방법 - -**기본 실행** (호스트): -```bash -python docker/k6/mqtt-load-test.py \ - --workers 5 \ - --rate 2 \ - --duration 60 -``` - -**환경 변수 오버라이드**: -```bash -MQTT_HOST=localhost MQTT_PORT=1883 python docker/k6/mqtt-load-test.py \ - --workers 5 --rate 2 --duration 60 -``` - -### 4.4 테스트 단계별 파라미터 - -| 단계 | Workers | Rate(/s) | Duration | 총 메시지 | 용도 | 예상 처리 시간 | -|------|---------|----------|----------|-----------|------|----------------| -| **Smoke** | 1 | 1 | 10s | ~10 | 기본 동작 확인 | ~30초 | -| **Load** | 5 | 2 | 60s | ~600 | 일반 부하 검증 | ~5분 | -| **Stress** | 20 | 5 | 120s | ~12,000 | 시스템 한계 확인 | ~30분 | -| **Soak** | 5 | 2 | 3600s | ~36,000 | 장시간 안정성 | ~2시간 | - -**추천 시작 순서**: -1. Smoke 테스트로 연결성 확인 -2. Load 테스트로 정상 동작 확인 -3. Stress 테스트로 한계 확인 - -### 4.5 메시지 형식 - -MQTT 메시지는 다음 JSON 형식으로 발행됩니다: - -```json -{ - "camera_id": "CAM-001", - "location": "서울시 강남구 테헤란로", - "detected_speed": 95.3, - "speed_limit": 60.0, - "detected_at": "2024-01-01T12:00:00+09:00", - "image_gcs_uri": "gs://speedcam-bucket/detections/1704067200000-1234.jpg" -} -``` - -**필드 설명**: -- `camera_id`: 카메라 ID (CAM-001 ~ CAM-020) -- `location`: 카메라 위치 (실제 한국 도로명) -- `detected_speed`: 감지된 속도 (제한속도 + 5~50km/h 초과) -- `speed_limit`: 해당 구간 제한속도 (60, 80, 100, 110 중 선택) -- `detected_at`: ISO 8601 형식의 감지 시간 (한국 표준시) -- `image_gcs_uri`: GCS에 저장된 이미지 경로 (시뮬레이션용 경로) - -### 4.6 실행 중 모니터링 - -테스트 실행 중 다른 터미널에서 진행 상황을 모니터링합니다: - -**RabbitMQ 큐 상태**: -```bash -curl -s -u sa:1234 http://localhost:15672/api/queues/%2F | python3 -c " -import json, sys -queues = json.load(sys.stdin) -print('RabbitMQ Queue Status:') -for q in queues: - if q['name'] in ('detections_queue', 'ocr_queue', 'fcm_queue'): - print(f\" {q['name']:20s} messages={q.get('messages', 0):6d} consumers={q.get('consumers', 0)}\") -" -``` - -**Celery 태스크 상태**: -```bash -curl -s http://localhost:5555/api/workers | python3 -c " -import json, sys -data = json.load(sys.stdin) -print('Celery Workers:') -for worker, info in data.items(): - print(f\" {worker:30s} {info.get('status', 'unknown')}\") -" -``` - -**Jaeger 트레이스 (선택)**: -```bash -open http://localhost:16686 -# Services → speedcam-api → Detection 또는 OCR 작업 선택 -``` - -### 4.7 결과 확인 - -MQTT 테스트 완료 후 stdout에 통계가 표시됩니다: - -``` -=== MQTT Load Test Complete === -Total Published: 600 -Failed: 0 -Success Rate: 100.00% -Avg Latency: 245ms -Min Latency: 50ms -Max Latency: 1200ms -Total Duration: 65 seconds -Messages/sec: 9.23 -``` - -**해석**: -- **Success Rate**: 100%이어야 함 (메시지 발행 성공) -- **Avg Latency**: MQTT 발행 시간 (네트워크 지연) -- **Total Duration**: 부하 테스트 총 소요 시간 - ---- - -## 5. End-to-End 검증 체크리스트 - -MQTT 부하 테스트 실행 후 다음 항목들을 확인하여 파이프라인이 정상 동작하는지 검증합니다. - -### 5.1 Detection 처리 상태 - -```bash -# 전체 Detection 조회 -curl -s http://localhost:8000/api/v1/detections/ | python3 -c " -import json, sys -data = json.load(sys.stdin) -print(f\"Total Detections: {data['count']}\") -print() - -# 상태별 카운트 추출 -results = data['results'] -status_counts = {} -for r in results: - status = r.get('ocr_status', 'unknown') - status_counts[status] = status_counts.get(status, 0) + 1 - -print('Status Distribution:') -for status, count in sorted(status_counts.items()): - print(f\" {status:15s}: {count:4d}\") -" -``` - -**정상 상태**: -- 대부분이 `completed` 상태 -- 일부 `processing` 또는 `pending` (최근 생성된 건) -- `failed` 건이 있으면 OCR Worker 로그 확인: `docker logs speedcam-ocr` - -```bash -# 최근 생성된 Detection 확인 -curl -s "http://localhost:8000/api/v1/detections/?ordering=-detected_at&limit=5" | \ - python3 -m json.tool | head -50 -``` - -### 5.2 Jaeger 분산 트레이스 확인 - -트레이스를 통해 요청이 전체 시스템을 거치는 과정을 추적합니다. - -```bash -# 사용 가능한 서비스 확인 -curl -s http://localhost:16686/api/services | python3 -c " -import json, sys -data = json.load(sys.stdin) -print('Jaeger Services:') -for service in data['data']: - print(f\" - {service}\") -" -``` - -**예상 서비스**: -- `speedcam-api`: Django 메인 애플리케이션 -- `speedcam-ocr`: OCR Worker (Celery) -- `speedcam-alert`: Alert Worker (Celery) - -```bash -# 최근 트레이스 조회 (speedcam-api) -curl -s "http://localhost:16686/api/traces?service=speedcam-api&limit=3" | python3 -c " -import json, sys -data = json.load(sys.stdin) -print('Recent Traces (speedcam-api):') -for trace in data['data'][:3]: - trace_id = trace['traceID'][:16] - num_spans = len(trace['spans']) - operation = trace['spans'][0]['operationName'] - duration_ms = (trace['spans'][0]['endTime'] - trace['spans'][0]['startTime']) / 1000 - print(f\" {trace_id}... | Spans: {num_spans:2d} | {operation:30s} | {duration_ms:6.1f}ms\") -" -``` - -**정상 구성**: -- Health Check: 1-2 spans (빠름) -- Vehicle Create: 3-5 spans (DB 쿼리 포함) -- Detection Create: 5-10 spans (MQTT, RabbitMQ, DB) -- OCR Task: 7-15 spans (GCS, API, DB) - -### 5.3 Loki 로그 확인 - -구조화된 로그를 통해 각 컴포넌트의 동작을 확인합니다. - -```bash -# Loki에서 수집된 로그 스트림 확인 -curl -sG http://localhost:3100/loki/api/v1/labels | python3 -c " -import json, sys -data = json.load(sys.stdin) -print('Loki Labels:') -print(f\" Available labels: {', '.join(data['data'][:5])}...\") -" -``` - -```bash -# speedcam 컨테이너의 최근 로그 (Loki) -curl -sG "http://localhost:3100/loki/api/v1/query" \ - --data-urlencode 'query={container=~"speedcam.*"}' \ - --data-urlencode 'limit=10' | python3 -c " -import json, sys -data = json.load(sys.stdin) -streams = data['data']['result'] -print(f'Log Streams Found: {len(streams)}') -for stream in streams[:3]: - container = stream['stream'].get('container', 'unknown') - num_entries = len(stream['values']) - print(f\" {container:30s}: {num_entries} log entries\") -" -``` - -**Grafana UI에서 로그 보기**: -1. Grafana → Explore → Loki -2. 쿼리: `{container=~"speedcam.*"}` -3. 각 로그 라인의 `trace_id=` 클릭 → Jaeger 트레이스 자동 이동 - -### 5.4 RabbitMQ 큐 상태 - -MQTT 메시지 처리 파이프라인의 큐 상태를 확인합니다. - -```bash -# 큐별 메시지 수 확인 -curl -s -u sa:1234 http://localhost:15672/api/queues/%2F | python3 -c " -import json, sys -queues = json.load(sys.stdin) -print('RabbitMQ Queue Status:') -print(f\"{'Queue Name':<20} {'Messages':>10} {'Consumers':>10} {'Ready':>10} {'Unacked':>10}\") -print('-' * 60) -for q in queues: - if q['name'] in ('detections_queue', 'ocr_queue', 'fcm_queue', 'dlq_queue'): - name = q['name'] - msgs = q.get('messages', 0) - consumers = q.get('consumers', 0) - ready = q.get('messages_ready', 0) - unacked = q.get('messages_unacknowledged', 0) - print(f'{name:<20} {msgs:>10} {consumers:>10} {ready:>10} {unacked:>10}') -" -``` - -**정상 상태**: -- **detections_queue**: 0 (Detection 생성 후 즉시 처리) -- **ocr_queue**: 0-10 (처리 중) -- **fcm_queue**: 0-5 (처리 중) -- **dlq_queue**: 0 (에러 없음) -- **consumers**: 각 큐당 1 이상 (worker가 리스닝 중) - -### 5.5 Prometheus 메트릭 확인 - -시스템 성능 메트릭을 Prometheus PromQL로 확인합니다. - -```bash -# Django HTTP 요청 메트릭 -curl -s http://localhost:9090/api/v1/query --data-urlencode \ - 'query=rate(django_http_requests_total[5m])' | python3 -c " -import json, sys -data = json.load(sys.stdin) -result = data['data']['result'] -if result: - print(f'Django HTTP Request Rate: {len(result)} series found') - print(f' Current RPS: {float(result[0][\"value\"][1]):.1f}') -else: - print('No Django metrics found') -" -``` - -```bash -# Celery 태스크 메트릭 -curl -s http://localhost:9090/api/v1/query --data-urlencode \ - 'query=rate(celery_tasks_total[5m])' | python3 -c " -import json, sys -data = json.load(sys.stdin) -result = data['data']['result'] -if result: - print(f'Celery Task Rate: {len(result)} series found') - for r in result[:3]: - state = r['metric'].get('state', 'unknown') - rate = float(r['value'][1]) - print(f\" {state:10s}: {rate:.1f} tasks/sec\") -else: - print('No Celery metrics found') -" -``` - -### 5.6 DB 성능 메트릭 - -```bash -# MySQL 활성 커넥션 수 -curl -s http://localhost:9090/api/v1/query --data-urlencode \ - 'query=mysql_global_status_threads_connected' | python3 -c " -import json, sys -data = json.load(sys.stdin) -result = data['data']['result'] -if result: - value = float(result[0]['value'][1]) - print(f'Active MySQL Connections: {int(value)}') -else: - print('No MySQL metrics found') -" -``` - -### 5.7 컨테이너 리소스 사용률 - -```bash -# 각 컨테이너 CPU 사용률 (%) - cAdvisor 필요 -curl -s http://localhost:9090/api/v1/query --data-urlencode \ - 'query=rate(container_cpu_usage_seconds_total{name=~"speedcam-.*"}[5m])*100' | python3 -c " -import json, sys -data = json.load(sys.stdin) -result = data['data']['result'] -if result: - print('Container CPU Usage (%):') - for r in result[:5]: - name = r['metric'].get('name', 'unknown') - cpu_usage = float(r['value'][1]) - print(f\" {name:30s}: {cpu_usage:6.2f}%\") -else: - print('No cAdvisor metrics found (Linux only)') -" -``` - -```bash -# 각 컨테이너 메모리 사용량 (MB) - cAdvisor 필요 -curl -s http://localhost:9090/api/v1/query --data-urlencode \ - 'query=container_memory_usage_bytes{name=~"speedcam-.*"}/1024/1024' | python3 -c " -import json, sys -data = json.load(sys.stdin) -result = data['data']['result'] -if result: - print('Container Memory Usage (MB):') - for r in result[:5]: - name = r['metric'].get('name', 'unknown') - memory_mb = float(r['value'][1]) - print(f\" {name:30s}: {memory_mb:7.1f} MB\") -else: - print('No cAdvisor metrics found (Linux only)') -" -``` - ---- - -## 6. 트러블슈팅 - -### 6.1 docker-compose 실행 오류 - -**오류**: `network speedcam-network not found` - -**원인**: 모니터링 스택만 단독으로 시작함 - -**해결**: -```bash -# 앱 스택을 먼저 시작 -docker compose -f docker-compose.yml up -d - -# 그 다음 모니터링 추가 -docker compose -f docker-compose.monitoring.yml up -d - -# 또는 함께 시작 -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d -``` - -### 6.2 Prometheus 타겟이 DOWN - -**오류**: Prometheus 대시보드에서 일부 타겟이 DOWN 상태 - -**celery-exporter가 재시작되는 경우**: -- **원인**: RabbitMQ보다 먼저 시작되어 broker 연결 실패 -- **해결**: 자동 복구됨 (`restart: unless-stopped`). 30초 기다린 후 확인 - -**mysqld-exporter가 DOWN**: -- **원인**: MySQL보다 먼저 시작됨 -- **해결**: 자동 복구됨. 30초 기다린 후 확인 - -**django가 DOWN**: -- **원인**: 앱 시작 실패 -- **해결**: - ```bash - docker logs speedcam-main - ``` - -### 6.3 cAdvisor 시작 실패 (macOS) - -**오류**: `cadvisor: error setting oom score: open /proc/.../oom_score_adj: no such file or directory` - -**원인**: cAdvisor는 Linux 전용이며 /proc 파일시스템 필요 - -**해결**: 예상 동작. macOS에서는 자동으로 제외됨 (`profiles: [linux]`). Linux에서만 실행하세요. - -### 6.4 Jaeger에 트레이스가 없음 - -**오류**: Jaeger UI에서 데이터가 보이지 않음 - -**원인**: OTEL_EXPORTER_OTLP_ENDPOINT 미설정 - -**해결**: -```bash -# backend.env 확인 -cat docker/backend.env | grep OTEL_EXPORTER_OTLP_ENDPOINT - -# 없으면 추가 -echo 'OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317' >> docker/backend.env - -# 앱 재시작 -docker compose -f docker-compose.yml up -d --force-recreate speedcam-main -``` - -### 6.5 Loki 429 Too Many Requests - -**오류**: Loki 쿼리 실패 with 429 status - -**원인**: 로그 스트림이 너무 많음 (한계 초과) - -**해결**: -```bash -# loki-config.yml에서 한계 증가 -# docker/monitoring/loki/loki-config.yml 수정 -# limits_config: -# max_global_streams_per_user: 20000 # 기본값 10000에서 증가 -``` - -### 6.6 환경 변수 변경 후 반영 안됨 - -**오류**: backend.env 변경 후 앱에 반영 안됨 - -**원인**: Docker restart는 env_file을 다시 읽지 않음 - -**해결**: -```bash -# --force-recreate 사용 -docker compose -f docker-compose.yml up -d --force-recreate speedcam-main -``` - -### 6.7 MQTT 연결 실패 - -**오류**: `python mqtt-load-test.py` 실행 시 연결 실패 - -**원인**: RabbitMQ MQTT 플러그인 미활성화 - -**확인**: -```bash -docker logs speedcam-rabbitmq | grep -i mqtt -# "MQTT plugin loaded" 메시지가 있어야 함 -``` - -**해결** (이미 자동 활성화됨): -docker-compose.yml의 rabbitmq command에 `rabbitmq_mqtt` 플러그인이 포함되어 있는지 확인 - -### 6.8 k6 테스트 타임아웃 - -**오류**: k6 테스트 중 `dial tcp: lookup main: no such host` - -**원인**: k6 컨테이너가 speedcam-network에 연결되지 않음 - -**해결**: docker-compose.monitoring.yml에서 k6 서비스가 올바른 네트워크 설정이 있는지 확인 - -```yaml -networks: - - speedcam-network # speedcam-network 참조 -``` - ---- - -## 7. 정리 및 종료 - -### 7.1 전체 종료 및 데이터 제거 - -```bash -cd docker - -# 컨테이너 + 볼륨 완전 제거 -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml down -v -``` - -### 7.2 모니터링 데이터만 삭제 - -런타임 데이터(Prometheus, Grafana, Loki)를 초기화합니다: - -```bash -rm -rf docker/monitoring/prometheus/data \ - docker/monitoring/loki/data \ - docker/monitoring/grafana/data -``` - -다시 시작하면 초기 상태로 복구됩니다. - -### 7.3 모니터링 스택만 종료 (앱 유지) - -앱은 계속 실행하고 모니터링만 종료: - -```bash -cd docker - -docker compose -f docker-compose.monitoring.yml down -``` - -나중에 모니터링을 다시 시작: - -```bash -docker compose -f docker-compose.monitoring.yml up -d -``` - -### 7.4 특정 컨테이너만 종료 - -```bash -# 개별 서비스 종료 -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml \ - stop speedcam-main speedcam-ocr - -# 개별 서비스 재시작 -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml \ - restart speedcam-main -``` - ---- - -## 8. 추가 자료 - -### 8.1 관련 문서 - -- [모니터링 스택 가이드](./MONITORING.md): 아키텍처, 설정, 메트릭 상세 설명 -- [배포 가이드](./DEPLOYMENT.md): GCP 멀티 인스턴스 배포 방법 -- [아키텍처 비교](./ARCHITECTURE_COMPARISON.md): 시스템 설계 이유 - -### 8.2 외부 자료 - -- [k6 공식 문서](https://k6.io/docs/) -- [Prometheus PromQL](https://prometheus.io/docs/prometheus/latest/querying/basics/) -- [Grafana 대시보드](https://grafana.com/grafana/dashboards/) -- [Jaeger 분산 트레이싱](https://www.jaegertracing.io/docs/) -- [OpenTelemetry Python](https://opentelemetry.io/docs/instrumentation/python/) - -### 8.3 자주 사용하는 명령어 - -```bash -# 모니터링 스택 전체 확인 -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml ps - -# 로그 실시간 추적 -docker logs -f speedcam-main -docker logs -f speedcam-ocr -docker logs -f speedcam-alert - -# 모니터링 데이터 초기화 후 재시작 -rm -rf docker/monitoring/*/data -docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d - -# Prometheus 메트릭 직접 조회 -curl -s http://localhost:9090/api/v1/query --data-urlencode 'query=' - -# RabbitMQ 큐 확인 -curl -s -u sa:1234 http://localhost:15672/api/queues/%2F - -# Jaeger 서비스 확인 -curl -s http://localhost:16686/api/services - -# Docker 디스크 정리 (주의: 사용하지 않는 모든 이미지/볼륨 제거) -docker system prune -a --volumes -``` - ---- - -## 9. FAQ - -**Q: k6과 MQTT 테스트 중 어느 것을 먼저 실행해야 하나요?** - -A: k6을 먼저 실행하세요. k6은 REST API만 테스트하므로 (순수 읽기 작업) 데이터베이스 상태에 영향을 주지 않습니다. MQTT 테스트는 실제 Detection을 생성하므로 나중에 실행하는 것이 좋습니다. - -**Q: 부하 테스트 중 시스템이 느려집니다. 어떻게 해야 하나요?** - -A: 정상입니다. 먼저 메트릭을 확인하세요: -1. Prometheus에서 CPU/메모리 사용률 확인 -2. RabbitMQ 큐 깊이 확인 (메시지 밀림) -3. MySQL 커넥션 풀 상태 확인 -4. 필요하면 docker-compose.yml의 리소스 제한(`resources`) 조정 - -**Q: 테스트 결과를 저장하고 싶습니다.** - -A: k6은 자동으로 Prometheus에 메트릭을 기록합니다. Prometheus → Export로 데이터를 JSON/CSV로 내보낼 수 있습니다. MQTT 테스트의 경우 stdout을 파일로 리다이렉트합니다: -```bash -python docker/k6/mqtt-load-test.py ... > test_results.txt -``` - -**Q: 모니터링 없이 성능 테스트를 실행할 수 있나요?** - -A: 가능합니다. k6 또는 MQTT 테스트 스크립트는 독립적으로 실행할 수 있습니다. 하지만 모니터링 없으면 결과를 측정하고 분석하기 어렵습니다. - -**Q: 프로덕션 환경에서 어떻게 테스트하나요?** - -A: 이 가이드는 로컬/개발 환경 기준입니다. 프로덕션 배포는 [GCP 멀티 인스턴스 배포 가이드](./DEPLOYMENT.md)를 참고하세요. 프로덕션에서는: -1. 전용 모니터링 인스턴스 사용 -2. Prometheus 보안 설정 (인증, TLS) -3. 백그라운드에서 정기적인 스모크 테스트 실행 -4. 알림 규칙(Alert) 설정 - ---- - -마지막 업데이트: 2024년 1월 diff --git a/docs/load-test-plan.md b/docs/load-test-plan.md index 561b39c..ccd0117 100644 --- a/docs/load-test-plan.md +++ b/docs/load-test-plan.md @@ -1,5 +1,55 @@ # SpeedCam 부하 테스트 계획서 +## 0. v1 vs v2 비교 테스트 전략 + +### 비교 목적 + +v1(모놀리식 동기 OCR) → v2(Event Driven Architecture) 아키텍처 전환의 성능 개선 효과를 **정량적으로 입증**하기 위해, 동일한 시나리오 구조로 양측을 테스트합니다. + +### 비교 가능한 시나리오 매핑 + +| 비교 항목 | v1 시나리오 | v2 시나리오 | 비교 가능 | 비고 | +|----------|-----------|-----------|----------|------| +| 순수 읽기 (대시보드) | A: dashboard_polling (3 VUs) | A: dashboard_polling (3 VUs) | Yes | 동일 구조 | +| 혼합 워크로드 | C: mixed_workload (0→9 VUs) | C: mixed_workload (0→9 VUs) | Yes | 읽기/쓰기 비율 유사 | +| 스파이크 내성 | D: spike (0→15 VUs) | D: spike_resilience (0→15 VUs) | Yes | 동일 VU 프로파일 | +| 스트레스 읽기 | E: stress_ramp (0→50 VUs) | E: stress_ramp (0→50 VUs) | Yes | 동일 VU 프로파일 | +| 스트레스 혼합 | F: stress_mixed (0→50 VUs) | F: stress_mixed (0→50 VUs) | Yes | **쓰기 내용 상이*** | +| 동기 OCR 부하 | B: sync_ocr_stress (2 VUs) | N/A | No | v2는 MQTT 파이프라인으로 분리 | +| MQTT 파이프라인 | N/A | MQTT 3 시나리오 | No | v1에는 MQTT 없음 | + +> *v1 stress_mixed 20% 쓰기 = **동기 OCR POST** (3~10초/건, HTTP 스레드 점유) +> v2 stress_mixed 20% 쓰기 = **차량 등록 POST** (<300ms, OCR과 무관) +> → **이 차이 자체가 핵심 비교 포인트: OCR 분리 효과** + +### 비교 불가능한 영역 + +- **v1 sync_ocr_stress** vs **v2 admin_ops**: v1은 OCR이 HTTP 동기 처리, v2는 OCR이 별도 Worker에서 비동기 처리. 구조적으로 다른 테스트. +- **v2 MQTT 파이프라인**: v1에는 MQTT가 없으므로 직접 비교 불가. v2 전용 성능 지표. + +### 핵심 비교 메트릭 매핑 + +| v1 메트릭 | v2 메트릭 | 의미 | 비고 | +|----------|----------|------|------| +| dashboard_req_duration | dashboard_req_duration | 대시보드 응답시간 | 공통 | +| cars_list_duration | detections_list_duration | 목록 조회 | 엔드포인트명 상이 | +| unchecked_req_duration | pending_read_duration | 미처리 목록 | 엔드포인트명 상이 | +| ocr_req_duration | N/A | 동기 OCR | v2는 MQTT 파이프라인 | +| N/A | statistics_req_duration | 통계 조회 | v2 전용 | +| stress_read_duration | stress_read_duration | 스트레스 읽기 | 공통 | +| stress_write_duration | stress_write_duration | 스트레스 쓰기 | **v1=OCR, v2=차량등록** | +| errors | errors | 에러율 | 공통 | +| total_requests | total_requests | 요청 수 | 공통 | + +### 테스트 실행 순서 + +1. **v1 baseline 확보**: `depoly-v1/k6/load-test-v1.js` 실행 (speedcam-v1-app, 10.178.0.8) +2. **v2 HTTP 테스트**: `backend/docker/k6/load-test.js` 실행 (speedcam-app, 10.178.0.4) +3. **v2 MQTT 테스트**: `backend/docker/k6/mqtt-load-test.py` 실행 +4. **결과 비교**: `performance-analysis.md` Section 5의 비교 표에 수치 기입 + +--- + 본 문서는 SpeedCam 프로젝트의 가설 기반 부하 테스트 계획을 제공합니다. --- @@ -15,13 +65,24 @@ - speedcam-alert: Celery Alert Worker - speedcam-mon: Prometheus + Grafana + Loki + Jaeger +### v1 인프라 구성 (비교 기준) + +> v1과 v2는 완전히 별도 인스턴스에서 운영됩니다. + +- 1개 GCP e2-standard-2 인스턴스 (2 vCPU, 4 GB RAM): + - speedcam-v1-app (10.178.0.8 / 34.64.68.137): Django + Gunicorn (동기 OCR) + MySQL + RabbitMQ (모놀리식) + - 별도 모니터링: Prometheus at 10.178.0.9 (v2 speedcam-mon 10.178.0.5와 다름) +- Gunicorn: 2 workers x 2 threads = 4 핸들러 +- OCR: 동기 처리 (HTTP 스레드 내에서 EasyOCR 실행, 3~10초/건) +- 테스트 스크립트: `depoly-v1/k6/load-test-v1.js` + ### env.example vs 배포 환경 차이 | 변수 | env.example 기본값 | 실제 배포값 | 영향 | |------|-------------------|------------|------| | `GUNICORN_WORKERS` | 4 | **2** | HTTP 처리 용량 절반 (8 → 4 핸들러) | -| `OCR_CONCURRENCY` | 4 | 4 | 차이 없음 | -| `ALERT_CONCURRENCY` | 100 | 100 | 차이 없음 | +| `OCR_CONCURRENCY` | **2** | **4** | OCR 동시 처리 2배 | +| `ALERT_CONCURRENCY` | **50** | **100** | Alert gevent pool 2배 | > **주의: 본 문서의 모든 용량 계산은 실제 배포 환경 값 기준입니다.** @@ -34,7 +95,8 @@ | 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 기준) | +| Alert 파이프라인 | ~500-2000 tasks/s | Kombu Consumer (단일 스레드) → Celery gevent pool (concurrency=100) × (5-20 tasks/s, FCM mock 기준) | +| Kombu Consumer | 단일 스레드 | 이벤트 디스패치 전용 (도메인 이벤트 수신 → send_notification.delay()) | | MySQL max_connections | 151 | MySQL 8.0 기본값 | | 예상 DB 연결 수 (부하 시) | ~12-20 | Gunicorn(4) + OCR(4) + Alert(100, pooled) + MQTT(1) | @@ -47,8 +109,10 @@ RaspPi MQTT publish (QoS 1) → 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 브로드캐스트 + 개별 푸시 + → 도메인 이벤트 발행: detections.completed (domain_events exchange, topic) + → Alert Worker Kombu Consumer: 이벤트 수신 + → send_notification.delay() → Celery gevent pool (fcm_queue) + → FCM topic 브로드캐스트 + 개별 푸시 → Notification.create() [notifications_db] ``` @@ -57,6 +121,7 @@ RaspPi MQTT publish (QoS 1) 2. **OCR Worker** - CPU 바운드, 4개 동시 처리 한정 3. **Gunicorn** - 4 핸들러, DB 연결 오버헤드 4. **MySQL** - 커넥션 풀링 없음, 부하 시 연결 폭주 +5. **Alert Worker Kombu Consumer** - 단일 스레드 이벤트 디스패치, 고속 이벤트 유입 시 잠재 병목 ### 사용 API 엔드포인트 @@ -73,6 +138,22 @@ RaspPi MQTT publish (QoS 1) ## 2. HTTP 테스트 시나리오 (k6) +### v1 HTTP 테스트 시나리오 (비교용) + +> 상세 구현은 `depoly-v1/k6/load-test-v1.js` 참조 + +| 시나리오 | v1 설명 | VUs | v2 대응 | 비교 가능 | +|---------|--------|-----|---------|----------| +| A: 대시보드 폴링 | 3 VUs, 2분 | 3 | dashboard_polling | Yes | +| B: 동기 OCR 스트레스 | 2 VUs, 2분 | 2 | N/A (MQTT 파이프라인) | No (구조적 차이) | +| C: 혼합 워크로드 | 0→9 VUs, 2분30초 | 0→9 | mixed_workload | Yes | +| D: 스파이크 내성 | 0→15 VUs, 1분10초 | 0→15 | spike_resilience | Yes | +| E: 스트레스 읽기 | 0→50 VUs, 3분30초 | 0→50 | stress_ramp | Yes | +| F: 스트레스 혼합 | 0→50 VUs, 3분 | 0→50 | stress_mixed | Yes (쓰기 내용 상이*) | + +> *v1 stress_mixed 쓰기 = 동기 OCR POST (3~10초), v2 stress_mixed 쓰기 = 차량 등록 POST (<300ms) +> → 이 차이가 핵심 비교 포인트: OCR 분리 효과 + ### 시나리오 A: 대시보드 폴링 (주요 읽기 부하) **설명:** 사용자가 대시보드를 열어놓고 주기적으로 데이터를 확인하는 패턴 @@ -374,83 +455,15 @@ python3 mqtt-load-test.py --scenario rush_hour \ --- -## 7. 테스트 실측 결과 (2026-02-12) - -> 실행 환경: 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` - -### 7.1 k6 HTTP 결과 (모든 임계치 PASS) - -**총 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 (이전 스트레스 테스트에서 확인) - -### 7.2 MQTT 파이프라인 결과 (OCR 병목 심각) - -> **중요: 아래 결과는 실제 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시간 소요 예상) - -**병목 순위 (실측 기반):** -1. **OCR Worker** — 실제 EasyOCR 처리 속도가 지배적 병목 (0.06 msg/s) -2. MQTT Subscriber — 단일 스레드이나, OCR 대비 충분히 빠름 (20 msg/s 발행 성공) -3. RabbitMQ — 메시지 버퍼링 정상, DLQ 0건 -4. Gunicorn/MySQL — HTTP 계층은 병목 아님 (p95 < 50ms) - ---- - -## 8. 테스트 환경 참고사항 +## 7. 테스트 환경 참고사항 - `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 단일 스레드 — 메시지 처리 직렬화됨 +- **Alert Worker 구성: Kombu Consumer (단일 스레드, 도메인 이벤트 수신) + Celery gevent pool (concurrency=100, FCM 발송)** + - Kombu Consumer는 `detections.completed` 이벤트를 수신하여 `send_notification.delay()` 호출 + - gevent pool은 FCM 발송 I/O 바운드 작업을 비동기 처리 + - `OTEL_PYTHON_AUTO_INSTRUMENTATION_EXPERIMENTAL_GEVENT_PATCH=patch_all` 설정 필요 (gevent DB 스레드 안전성 — `docs/GEVENT_DB_THREAD_SAFETY.md` 참조) - k6를 대상 서버와 동일 인스턴스에서 실행하면 CPU/메모리 경합 발생 (별도 인스턴스 권장) diff --git a/docs/load-testing.md b/docs/load-testing.md deleted file mode 100644 index e62d644..0000000 --- a/docs/load-testing.md +++ /dev/null @@ -1,878 +0,0 @@ -# SpeedCam 부하 테스트 전략 - -## 개요 - -SpeedCam은 IoT 카메라에서 MQTT를 통해 이미지를 수신하고, Django 애플리케이션이 AMQP/Celery를 통해 OCR 작업을 처리한 후, 감지된 결과를 Alert Service로 전달하는 완전한 파이프라인 아키텍처입니다. 이 문서는 이 복잡한 시스템의 성능을 검증하고 병목 지점을 식별하기 위한 부하 테스트 전략을 설명합니다. - -## 시스템 아키텍처 - -### 처리 파이프라인 - -``` -IoT Camera (MQTT) - ↓ -Main Service (Django + Gunicorn + MQTT Subscriber) - ↓ -AMQP/Celery Queue - ↓ -OCR Worker (EasyOCR on CPU) - ↓ -AMQP Domain Event (detections.completed) - ↓ -Alert Service (kombu consumer → FCM) -``` - -### 배포 인프라 (GCE asia-northeast3-a) - -| 인스턴스 | 역할 | 핵심 설정 | -|---------|------|---------| -| **speedcam-app** | Django + Gunicorn + MQTT Subscriber | GUNICORN_WORKERS=2 | -| **speedcam-db** | MySQL 8.0 데이터베이스 | - | -| **speedcam-mq** | RabbitMQ (MQTT plugin + AMQP) | - | -| **speedcam-ocr** | Celery OCR Worker (EasyOCR) | OCR_CONCURRENCY=1 | -| **speedcam-alert** | Domain Event Consumer → FCM | FCM_MOCK=true | -| **speedcam-mon** | 모니터링 스택 | Prometheus, Grafana, Loki, Jaeger | - -## 측정된 성능 기준치 (Baselines) - -현재 환경에서 측정된 주요 성능 메트릭: - -| 메트릭 | 값 | 설명 | -|-------|-----|------| -| **MQTT 발행 지연** | ~0.3ms | 카메라에서 브로커까지의 지연 시간 | -| **OCR 처리 시간** | 4-5s | 캐시된 EasyOCR Reader로 단일 이미지 처리 | -| **OCR 모델 초기 로딩** | ~30s | EasyOCR 모델 처음 로드 시간 | -| **OCR 최대 처리량** | ~0.2 msg/s | CONCURRENCY=1 설정에서의 이론적 한계 | -| **Alert 이벤트 처리** | <1s | detections.completed 이벤트에서 FCM 전송까지 | -| **엔드-투-엔드 지연** | ~5-6s | MQTT 발행에서 Alert 완료까지 전체 파이프라인 | - -## 테스트 시나리오 - -부하 테스트는 다양한 부하 프로파일 하에서 시스템의 동작을 검증하기 위해 5개의 시나리오로 구성됩니다. - -### 시나리오 정의표 - -| 시나리오 | 워커 수 | 전송률 | 지속시간 | 총 메시지 수 | 목표 및 검증 사항 | -|---------|--------|-------|---------|------------|-----------------| -| **smoke** | 1 | 1 msg (수동) | - | 1 | 파이프라인 기본 동작 검증 | -| **baseline** | 1 | 0.2/s | 60s | ~12 | OCR 최대 처리량 수준에서 안정성 검증 | -| **saturation** | 3 | 1/s | 60s | ~180 | OCR 워커 포화 상태 시뮬레이션 | -| **spike** | 5 | 2/s | 10s | ~100 | 갑작스러운 트래픽 증가 대응 능력 검증 | -| **sustained** | 2 | 0.5/s | 300s | ~300 | 장시간 안정적 운영 능력 검증 | - -### 각 시나리오의 상세 설명 - -#### 1. Smoke Test (스모크 테스트) -- **목적**: 파이프라인이 정상 작동하는지 기본 검증 -- **가설**: 단일 메시지는 지연 없이 전체 파이프라인을 통과 -- **수행 방법**: 수동으로 하나의 MQTT 메시지 발행 -- **검증 항목**: - - 메시지가 speedcam-app에서 수신됨 - - Celery 작업이 생성됨 - - OCR 처리 완료 - - Alert 이벤트가 발행됨 - - 종단간 지연이 ~5-6초 범위 - -#### 2. Baseline Test (기준선 테스트) -- **목적**: OCR 워커의 이론적 최대 처리량에서 안정성 검증 -- **가설**: OCR_CONCURRENCY=1 설정에서 0.2 msg/s 지속 가능 -- **초기화**: speedcam-ocr 워커 재시작하여 모델 미리 로드 -- **수행 방법**: 1개 워커가 12개 메시지를 60초에 걸쳐 0.2/s 속도로 발행 -- **검증 항목**: - - 모든 메시지가 처리됨 (100% 완료율) - - 큐 깊이가 안정적으로 유지됨 - - 데이터베이스 연결이 누적되지 않음 - - 종단간 지연이 안정적 (5-6초 범위) - -#### 3. Saturation Test (포화 테스트) -- **목적**: OCR 워커 포화 상태에서 시스템의 대응 능력 검증 -- **가설**: 1/s 속도로 메시지가 쌓이면 큐 깊이 증가하며, 메시지 손실 없이 처리됨 -- **수행 방법**: 3개 워커가 180개 메시지를 60초에 걸쳐 1/s 속도로 발행 -- **검증 항목**: - - 큐 최대 깊이 관찰 (이론값: ~12) - - 메시지 손실 0건 - - 종단간 지연 증가 추이 (선형 증가 예상) - - Celery 작업 타임아웃 발생 여부 - - 데이터베이스 연결 고갈 여부 - -#### 4. Spike Test (스파이크 테스트) -- **목적**: 갑작스러운 트래픽 급증에 대한 시스템 회복 능력 검증 -- **가설**: 짧은 기간의 고속 전송 후 시스템이 정상으로 복구 -- **수행 방법**: 5개 워커가 100개 메시지를 10초에 걸쳐 2/s 속도로 발행 -- **검증 항목**: - - 스파이크 중 큐 최대 깊이 - - 메시지 손실 여부 - - 완료 후 큐 정상화 시간 - - 메모리 누수 증상 (메모리 사용량 회귀) - -#### 5. Sustained Test (지속성 테스트) -- **목적**: 장시간 안정적 운영 능력 검증 및 메모리 누수 감지 -- **가설**: 0.5/s 지속 속도로 300초 동안 모든 메시지 처리, 메모리 누수 없음 -- **수행 방법**: 2개 워커가 300개 메시지를 300초에 걸쳐 0.5/s 속도로 발행 -- **검증 항목**: - - 메시지 처리율 일정 유지 (100%) - - 메모리 사용량 추이 (증가 아닌 안정) - - CPU 사용률 안정성 - - 완료된 메시지 수 = 300건 - -## 가설-실행-비교 프레임워크 - -부하 테스트는 다음의 명확한 프레임워크를 따릅니다: - -### 1. 사전 가설 수립 (Pre-Test Hypothesis) - -각 시나리오 시작 전에 예상 결과를 명확히 정의합니다: - -``` -테스트 시작 전: -├─ 예상 완료 메시지 수 -├─ 예상 최대 큐 깊이 -├─ 예상 종단간 지연 범위 -├─ 예상 리소스 사용률 범위 -└─ 예상 오류율 (0% 또는 허용 범위) -``` - -테스트 스크립트는 다음과 같이 가설을 출력합니다: - -```python -# 예시 -print(f""" -=== Baseline Test Hypothesis === -Expected Message Completion: 12 (100%) -Expected Queue Depth: <1 (stable) -Expected E2E Latency: 5-6 seconds -Expected Error Rate: 0% -Expected OCR Processing Time: ~4-5s per image -Resource Baseline: Monitor CPU and Memory -""") -``` - -### 2. 테스트 실행 (Test Execution) - -실시간으로 시스템 상태를 모니터링하면서 테스트를 실행합니다: - -``` -테스트 진행 중: -├─ 메시지 발행률 확인 -├─ 실시간 큐 깊이 모니터링 -├─ Celery 작업 상태 추적 -├─ 에러 로그 감시 -└─ 리소스 사용률 관찰 -``` - -테스트 스크립트는 진행 상황을 실시간으로 출력합니다: - -``` -[T+5s] Published: 1/12 | Queue Depth: 0 | OCR Processing: 1 | Completed: 0 -[T+10s] Published: 2/12 | Queue Depth: 0 | OCR Processing: 1 | Completed: 1 -[T+15s] Published: 3/12 | Queue Depth: 1 | OCR Processing: 1 | Completed: 1 -... -``` - -### 3. 결과 비교 및 분석 (Comparison & Analysis) - -테스트 완료 후, 실제 결과를 사전 가설과 비교합니다: - -#### 파이프라인 검증 쿼리 - -테스트 완료 후 데이터베이스를 직접 쿼리하여 메시지 처리 상황을 확인합니다: - -```sql --- Detection 테이블 확인 (OCR 처리된 메시지) -SELECT - COUNT(*) as total_detections, - COUNT(CASE WHEN status='completed' THEN 1 END) as completed, - COUNT(CASE WHEN status='failed' THEN 1 END) as failed, - COUNT(CASE WHEN status='processing' THEN 1 END) as still_processing -FROM detections -WHERE created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE); - --- 시간별 처리 완료 현황 -SELECT - DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:00') as minute, - COUNT(*) as completed_count -FROM detections -WHERE status='completed' AND created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE) -GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:00') -ORDER BY minute; - --- 처리 시간 분석 -SELECT - MIN(DATE_FORMAT(TIMEDIFF(updated_at, created_at), '%H:%i:%s')) as min_duration, - MAX(DATE_FORMAT(TIMEDIFF(updated_at, created_at), '%H:%i:%s')) as max_duration, - AVG(TIME_TO_SEC(TIMEDIFF(updated_at, created_at))) as avg_seconds -FROM detections -WHERE status='completed' AND created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE); -``` - -#### 결과 비교표 - -테스트 결과를 가설과 비교하는 형식: - -``` -=== Baseline Test Results === -Metric | Hypothesis | Actual | Status | Analysis -Total Messages | 12 (100%) | 12 (100%) | PASS | All messages processed -Queue Depth Peak | <1 | 0 | PASS | No queuing observed -E2E Latency Range | 5-6s | 5.2-5.8s | PASS | Within expected range -Error Rate | 0% | 0% | PASS | No processing errors -OCR Processing Time | 4-5s avg | 4.6s avg | PASS | Consistent with baseline -Memory Leak | None | Stable | PASS | No memory increase -DB Connections | <5 | 2-3 | PASS | No connection pooling issues -``` - -### 4. 의사결정 기준 - -결과 비교 후 다음 단계를 결정합니다: - -| 결과 | 의사결정 | -|-----|--------| -| **모두 PASS** | 다음 시나리오 진행 | -| **부분 FAIL (메시지 처리 완료)** | 병목 분석 후 진행 (성능 개선 필요) | -| **메시지 손실** | 중단 및 구성 검토 필요 | -| **시스템 크래시** | 중단 및 디버깅 필요 | - -## 테스트 실행 방법 - -### 사전 요구사항 - -```bash -# GCE 인스턴스에 SSH 접속 -gcloud compute ssh speedcam-app --zone=asia-northeast3-a - -# Docker 컨테이너 내부 접속 -sudo docker exec -it speedcam-main bash - -# 환경 변수 설정 -export MQTT_PASS= -export MQTT_HOST=speedcam-mq -export MQTT_PORT=1883 -``` - -### 테스트 스크립트 위치 - -``` -/app/docker/k6/mqtt-load-test.py # 주요 테스트 스크립트 -/app/docker/k6/load-test.js # k6 HTTP API 테스트 (보조) -``` - -### 시나리오별 실행 명령 - -#### 1. Smoke Test (스모크 테스트) - -```bash -# 모든 서비스 준비 상태 확인 -python /app/docker/k6/mqtt-load-test.py smoke - -# 예상 출력: -# === Smoke Test Starting === -# Publishing 1 test message... -# [T+0.5s] Message published -# [T+5s] Detection created in database -# [T+5.5s] Alert event published -# === Smoke Test PASSED === -``` - -#### 2. Baseline Test (기준선 테스트) - -```bash -# OCR 워커가 모델을 미리 로드하도록 대기 (약 30초) -# 모니터링 터미널에서 Grafana 대시보드 준비 - -python /app/docker/k6/mqtt-load-test.py baseline - -# 예상 소요 시간: 약 70초 (60초 + 처리 완료 대기) -# 예상 완료 메시지: 12건 -``` - -#### 3. Saturation Test (포화 테스트) - -```bash -# 주의: 이 테스트는 큐 깊이를 증가시킵니다 -# 모니터링 대시보드 확인 준비 - -python /app/docker/k6/mqtt-load-test.py saturation - -# 예상 소요 시간: 약 150초 (60초 + 큐 처리 완료 대기) -# 예상 완료 메시지: 180건 -``` - -#### 4. Spike Test (스파이크 테스트) - -```bash -# 단기간 높은 처리율 테스트 - -python /app/docker/k6/mqtt-load-test.py spike - -# 예상 소요 시간: 약 90초 (10초 + 큐 처리 완료 대기) -# 예상 완료 메시지: 100건 -``` - -#### 5. Sustained Test (지속성 테스트) - -```bash -# 장시간 안정성 테스트 - 커피를 준비하세요 -# 이 테스트는 약 8-10분 소요됩니다 - -python /app/docker/k6/mqtt-load-test.py sustained - -# 예상 소요 시간: 약 600초 (300초 + 큐 처리 완료 대기) -# 예상 완료 메시지: 300건 -``` - -### 테스트 중단 및 정리 - -```bash -# 테스트를 강제 중단해야 하는 경우 (Ctrl+C) -# Celery 큐에 남아있는 작업을 확인 -python -c "from celery_app import app; print(app.control.inspect().active())" - -# 필요시 큐 초기화 (주의: 처리 중인 작업도 제거됨) -python -c "from celery_app import app; app.control.purge()" - -# 데이터베이스 테스트 데이터 정리 -python manage.py shell ->>> from detections.models import Detection ->>> Detection.objects.filter(created_at__gt=timezone.now()-timedelta(hours=1)).delete() -``` - -## 결과 해석 및 성능 분석 - -### 메트릭 정의 - -#### 완료 메시지 (Completed Messages) -- 정의: MQTT 발행에서 FCM Alert까지 전체 파이프라인 완료 -- PASS 기준: 예상 메시지 수의 100% -- FAIL 기준: 1건 이상의 메시지 손실 - -#### 큐 깊이 (Queue Depth) -- 정의: RabbitMQ AMQP 큐에 대기 중인 Celery 작업 수 -- 모니터링: `rabbitmqctl list_queues` 또는 Grafana -- 분석: - - Baseline에서 큐 깊이 > 2: OCR 처리 능력 부족 - - Saturation에서 큐 깊이 선형 증가: 예상된 동작 - - Sustained 후 큐 깊이 0으로 복귀: 정상 종료 - -#### 종단간 지연 (End-to-End Latency) -- 정의: MQTT 발행부터 Alert 완료까지의 경과 시간 -- 측정: Database detection.created_at → alert_events.published_at -- 분석: - - Baseline: 5-6초 (안정적) - - Saturation: 5초 + (큐_깊이 × 4초) (선형 증가) - - Spike 후 정상화: 초기 지연 증가 → 정상 복귀 - -#### 오류율 (Error Rate) -- 정의: 처리 실패한 메시지 비율 -- PASS 기준: 0% -- 감지 방법: - - Celery 작업 failed 상태 - - Loki 로그의 ERROR, EXCEPTION 레벨 - - Database detection.status='failed' - -### 결과별 해석 가이드 - -#### Baseline Test 결과 해석 - -**완전 통과 (All PASS)** -``` -완료: 12/12 (100%) -큐 최대: 0 -E2E 지연: 5.2-5.8s 평균 5.5s -오류율: 0% - -→ 해석: 시스템이 설계된 대로 동작. OCR 최대 처리량을 안전하게 유지. -→ 다음: Saturation 테스트 진행. -``` - -**부분 실패 (Partial FAIL) - 메시지 손실 없음** -``` -완료: 12/12 (100%) -큐 최대: 1-2 -E2E 지연: 5.5-7.2s 평균 6.2s -오류율: 0% - -→ 해석: 메시지는 모두 처리되지만 약간의 지연 발생. -→ 원인 분석: - - 데이터베이스 연결 대기 - - GC pause 영향 - - MQTT 브로커 내부 처리 지연 -→ 다음: Grafana에서 상세 분석 (CPU, 메모리, DB 연결) -``` - -**실패 (FAIL) - 메시지 손실** -``` -완료: 10/12 (83%) -손실: 2 -큐 최대: 5+ -오류율: 16.7% - -→ 해석: 메시지 손실 발생 - 심각한 문제. -→ 즉시 조치: - 1. Celery 워커 로그 확인 - docker logs speedcam-ocr | tail -100 - 2. Loki에서 ERROR 레벨 로그 검색 - {job="celery-worker"} | json | status="ERROR" - 3. RabbitMQ 상태 확인 - docker exec speedcam-mq rabbitmqctl status -→ 다음: 원인 제거 후 Baseline 재실행. -``` - -#### Saturation Test 결과 해석 - -**정상 포화 (Expected Behavior)** -``` -완료: 180/180 (100%) -큐 최대: 8-12 (이론값: (1.0-0.2)×60 = 48s×1msg/s ≈ 10-12) -E2E 지연: 초기 5-6s → 최대 50-52s -오류율: 0% - -→ 해석: OCR이 포화되었으나 메시지 손실 없음 (큐 기반 처리). -→ 시스템이 설계된 대로 부하를 흡수. -→ 다음: Spike 테스트 진행. -``` - -**예상과 다른 포화 (Anomaly)** -``` -완료: 180/180 (100%) -큐 최대: >30 (예상 10-12) -E2E 지연: 100초 이상 -오류율: 0% 하지만 일부 타임아웃 - -→ 해석: OCR 처리 속도가 예상보다 느림. -→ 원인 분석: - - OCR 모델 재로드되었을 가능성 (메모리 부족) - - CPU 과부하 또는 열 제한 - - 데이터베이스 슬로우 쿼리 -→ 다음: 인프라 점검 후 baseline 재실행. -``` - -### 병목 지점 식별 및 개선 - -#### 현재 알려진 병목: OCR 처리 (Primary Bottleneck) - -**증상:** -- OCR_CONCURRENCY=1로 설정되어 있음 -- 이론적 최대 처리량: 0.2 msg/s (4-5초/이미지) -- 단일 CPU 코어에서 순차 처리 - -**개선 방안:** - -```bash -# 1단계: 멀티코어 EasyOCR (실험) -# Saturation 결과를 토대로 확인 -# 큐가 10개 이상 쌓이면 다음 단계 고려 - -# 2단계: OCR_CONCURRENCY 증가 (권장) -# speedcam-ocr 환경 변수 수정 -export OCR_CONCURRENCY=2 # 이론상 0.4 msg/s 가능 - -# Docker 재시작 -sudo docker restart speedcam-ocr - -# Baseline 재테스트 -python /app/docker/k6/mqtt-load-test.py baseline - -# 결과: 완료 시간 50% 단축 예상 (12 × 5s / 2 = 30s) -``` - -**OCR_CONCURRENCY 증가 시 고려사항:** -- 메모리 사용량 증가 (각 워커당 GiB급) -- CPU 코어 수 제한 (GCE 인스턴스 코어 수 확인) -- 온도 및 열 제한 (GPU 없이 CPU만 사용) - -#### 2차 병목: Gunicorn 워커 (Secondary Bottleneck) - -**현재 상태:** -- GUNICORN_WORKERS=2 설정 -- MQTT 수신은 별도 스레드에서 처리 -- HTTP API는 Gunicorn 워커 풀 공유 - -**확인 방법:** -```bash -# Saturation 테스트 중 Grafana 확인 -# Gunicorn worker utilization을 모니터링 -# 만약 모든 워커가 항상 바쁘다면: - -# 로그에서 "worker timeout" 확인 -docker logs speedcam-app 2>&1 | grep -i timeout - -# 필요시 GUNICORN_WORKERS 증가 -export GUNICORN_WORKERS=4 -``` - -#### 3차 병목: MySQL 데이터베이스 (Tertiary Bottleneck) - -**감지:** -```sql --- 데이터베이스 연결 확인 -SHOW PROCESSLIST; - --- 슬로우 쿼리 확인 -SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 10; - --- 테이블 락 확인 -SHOW OPEN TABLES WHERE In_use > 0; -``` - -**개선:** -```bash -# 1. 인덱스 확인 -# detections 테이블의 created_at, status에 인덱스 있는지 확인 - -# 2. 연결 풀 크기 확인 -# Django DATABASES 설정의 CONN_MAX_AGE - -# 3. 필요시 마스터-슬레이브 구성 검토 -``` - -#### MQTT Subscriber 병목 가능성 - -**현재 아키텍처:** -- Django 애플리케이션 내부 MQTT 클라이언트 (단일 스레드) -- 블로킹 구독 모델 - -**부하 테스트에서 영향:** -- 메시지 발행률이 초당 1개 이상일 때 순차 처리 -- 단일 스레드이므로 CPU 활용도가 낮을 수 있음 - -**확인:** -```bash -# 테스트 중 프로세스 상태 확인 -docker top speedcam-app | head -20 - -# 만약 MQTT 수신 thread가 항상 busy면: -# MQTT 클라이언트 최적화 필요 -``` - -## 모니터링 및 관찰 - -### Grafana 대시보드 사용 - -테스트 진행 중 다음 대시보드를 지속적으로 관찰합니다: - -#### 1. SpeedCam 애플리케이션 대시보드 - -``` -URL: http://speedcam-mon:3000/d/speedcam-app/speedcam-application - -주요 패널: -├─ MQTT Messages Received (per second) -│ └─ 값이 설정된 발행율과 일치하는지 확인 -├─ Celery Queue Depth (tasks) -│ └─ Baseline: ~0, Saturation: 8-12, Spike: 최대값 관찰 -├─ OCR Processing Time (histogram) -│ └─ 평균값이 4-5초 범위인지 확인 -└─ End-to-End Latency (percentiles) - └─ P50: 5-6초, P99: 큐_깊이에 비례하여 증가 -``` - -#### 2. RabbitMQ 모니터링 대시보드 - -``` -URL: http://speedcam-mq:15672 (username: guest, password: guest) - -관찰 항목: -├─ Queue: celery (Messages) -│ └─ Ready: 처리 대기 중인 작업 -│ └─ Unacked: 처리 중인 작업 -├─ Queue: detections.completed -│ └─ Alert Service가 처리하는 이벤트 흐름 -└─ Consumers - └─ OCR Worker 연결 상태 확인 -``` - -#### 3. MySQL 성능 모니터링 - -``` -Grafana 패널: Database Performance - -확인 항목: -├─ Queries per second -├─ Average query execution time -├─ Active connections -├─ Slow queries (>1 second) -└─ Innodb buffer pool hit ratio (>99% 목표) -``` - -### Prometheus 메트릭 쿼리 - -테스트 중 다음 메트릭을 직접 쿼리하여 확인합니다: - -```promql -# MQTT 수신 메시지율 -rate(mqtt_messages_received_total[1m]) - -# Celery 큐 깊이 (현재값) -celery_queue_length{queue="celery"} - -# OCR 처리 시간 (평균) -rate(ocr_processing_time_seconds_sum[5m]) / -rate(ocr_processing_time_seconds_count[5m]) - -# 완료된 감지 이벤트 -rate(detections_completed_total[1m]) - -# Alert 발행 이벤트 -rate(alert_events_published_total[1m]) - -# 데이터베이스 연결 -mysql_global_status_threads_connected -``` - -### Loki 로그 쿼리 - -오류 또는 의심스러운 동작을 추적하기 위해 다음 로그 쿼리를 사용합니다: - -```loki -# OCR 워커 에러 -{job="celery-worker"} | json | level="ERROR" - -# Celery 작업 타임아웃 -{job="celery-worker"} | json | msg=~".*timeout.*" - -# Django 애플리케이션 에러 -{job="django-app"} | json | level="ERROR" - -# MQTT 연결 문제 -{job="django-app"} | json | msg=~".*mqtt.*error.*" - -# 데이터베이스 연결 에러 -{job="django-app"} | json | msg=~".*database.*connection.*" - -# 지난 5분간의 모든 ERROR 레벨 로그 개수 -count( - {job=~"celery-worker|django-app"} - | json - | level="ERROR" -) by (job) -``` - -### Jaeger 분산 추적 (Distributed Tracing) - -선택적으로 완전한 요청 흐름을 추적합니다: - -``` -URL: http://speedcam-mon:6831/search - -추적 항목: -1. MQTT 메시지 수신 스팬 - ├─ MQTT publish (IoT Camera) - ├─ MQTT message received (Django) - └─ Celery task enqueue - -2. OCR 처리 스팬 - ├─ Celery task start - ├─ EasyOCR model load (초회) - ├─ Image preprocessing - ├─ OCR inference - └─ Celery task complete - -3. Alert 발행 스팬 - ├─ Domain event created - ├─ kombu consumer received - ├─ FCM API call - └─ Alert published -``` - -## 문제 해결 및 FAQ - -### Q: Baseline 테스트에서 메시지가 처리되지 않음 - -**A: 다음을 순서대로 확인하세요:** - -```bash -# 1. MQTT 브로커 연결 확인 -docker exec speedcam-mq mosquitto_sub -h localhost -t "#" & -# (다른 터미널에서) python /app/docker/k6/mqtt-load-test.py smoke - -# 2. Celery 워커 상태 확인 -docker exec speedcam-ocr celery -A ocr_tasks inspect active - -# 3. Django 애플리케이션 로그 확인 -docker logs speedcam-app | tail -50 | grep -i error - -# 4. RabbitMQ 큐 상태 -docker exec speedcam-mq rabbitmqctl list_queues -``` - -### Q: OCR 워커가 응답하지 않음 - -**A:** - -```bash -# 1. EasyOCR 모델 로드 상태 확인 -# 첫 테스트 실행 시 약 30초 소요 (로그에서 확인) -docker logs speedcam-ocr | grep -i "loading\|model" - -# 2. 메모리 부족 여부 확인 -docker stats speedcam-ocr - -# 만약 메모리 사용량이 95% 이상: -# OCR_CONCURRENCY를 1로 유지하거나 -# 인스턴스 메모리 증설 필요 - -# 3. 워커 재시작 -docker restart speedcam-ocr - -# 모델 다시 로드될 때까지 대기 (30초) -sleep 30 -python /app/docker/k6/mqtt-load-test.py smoke -``` - -### Q: 테스트 중 "Task timed out" 에러 발생 - -**A:** - -```bash -# 1. Celery 타임아웃 설정 확인 -# celery_config.py의 task_soft_time_limit, task_time_limit 확인 - -# 2. 원인별 대응: -# - OCR 처리 시간 > 5초인 경우: 정상 (이미지 크기 또는 모델 특성) -# - OCR 처리 시간 > 30초인 경우: 모델 재로드 또는 하드웨어 문제 -# → docker restart speedcam-ocr - -# 3. 타임아웃 시간 증가 (필요시) -export CELERY_TASK_TIME_LIMIT=600 # 10분 -docker restart speedcam-ocr -``` - -### Q: 메모리 사용량이 계속 증가함 - -**A: 메모리 누수 가능성** - -```bash -# 1. 현재 메모리 사용량 추이 확인 -watch -n 1 'docker stats speedcam-app --no-stream | head -2' - -# 2. Grafana에서 Memory Usage 그래프 확인 -# Sustained 테스트 후 메모리가 복구되지 않으면 누수 가능 - -# 3. 원인 분석: -# - Database 연결 누적: Django ORM 미사용 연결 -# → Django 설정의 CONN_MAX_AGE 확인 -# -# - Celery 작업 메타데이터 누적 -# → RabbitMQ 퍼지 또는 설정 검토 -# -# - Python 객체 참조 순환 -# → 메모리 프로파일링 도구 (memory_profiler) 사용 - -# 4. 재시작 -docker restart speedcam-app speedcam-ocr speedcam-alert -``` - -### Q: Saturation 테스트 후 큐가 비지 않음 - -**A:** - -```bash -# 1. 남아있는 작업 확인 -docker exec speedcam-mq rabbitmqctl list_queues - -# 2. Celery 워커 상태 확인 -docker exec speedcam-ocr celery -A ocr_tasks inspect active - -# 3. 만약 워커가 멈춘 경우: -docker restart speedcam-ocr - -# 4. 큐 수동 퍼지 (데이터 손실 주의) -docker exec speedcam-mq rabbitmqctl purge_queue celery - -# 5. 데이터베이스 정리 -docker exec speedcam-app python manage.py shell -# >>> from detections.models import Detection -# >>> Detection.objects.filter(created_at__gt=...).delete() -``` - -## 체크리스트 - -테스트를 시작하기 전에 다음을 확인하세요: - -### 사전 점검 - -- [ ] 모든 인스턴스(speedcam-app, speedcam-db, speedcam-mq, speedcam-ocr, speedcam-alert, speedcam-mon)가 실행 중 -- [ ] SSH 접속 가능 (`gcloud compute ssh speedcam-app --zone=asia-northeast3-a`) -- [ ] Docker 컨테이너 접속 가능 (`docker exec -it speedcam-main bash`) -- [ ] MQTT 환경 변수 설정 (`MQTT_PASS` 포함) -- [ ] RabbitMQ 웹 UI 접속 가능 (http://speedcam-mq:15672) -- [ ] Grafana 접속 가능 (http://speedcam-mon:3000) -- [ ] MySQL 접속 가능 (`docker exec speedcam-db mysql -u root -p`) - -### 테스트별 점검 - -#### Smoke Test 전 -- [ ] Celery 워커 상태: active/idle -- [ ] RabbitMQ 큐: celery, detections.completed 모두 empty -- [ ] 데이터베이스: 최근 detections 없음 - -#### Baseline Test 전 -- [ ] OCR 모델 미리 로드 (약 30초 대기 후 확인) -- [ ] Grafana 대시보드 새로고침 -- [ ] 모니터링 터미널 준비 (2개: 스크립트 + 로그) - -#### Saturation Test 전 -- [ ] Spike 테스트 완료 후 큐 비워짐 확인 -- [ ] 메모리 사용량 정상 범위 확인 -- [ ] Grafana 범위 설정 변경 (Y축 스케일 확인) - -#### Spike Test 전 -- [ ] 최근 테스트 결과 분석 완료 -- [ ] 병목 지점 파악 완료 - -#### Sustained Test 전 -- [ ] 충분한 시간 확보 (약 10분) -- [ ] 모니터링 도구 안정성 확인 -- [ ] 로그 수집 설정 확인 (Loki) - -### 테스트 후 정리 - -- [ ] 모든 완료 메시지 수 기록 -- [ ] 최대 큐 깊이 기록 -- [ ] 주요 오류 로그 저장 -- [ ] Grafana 스크린샷 캡처 -- [ ] 분석 결과 문서화 -- [ ] 데이터베이스 테스트 데이터 정리 (필요시) -- [ ] 다음 테스트 시나리오 계획 - -## 결론 및 권장사항 - -### 현재 성능 프로필 요약 - -SpeedCam 시스템은 다음과 같은 성능 특성을 보입니다: - -- **최대 안전 처리 속도**: ~0.2 msg/s (OCR 병목) -- **포화 상태 큐 깊이**: ~10-12 작업 -- **종단간 지연**: 5-6초 (baseline) ~ 50초+ (saturation) -- **안정성**: 메시지 손실 0%, 메모리 누수 없음 - -### 향후 개선 계획 - -**Phase 1 (즉시 실행 가능)** -1. OCR_CONCURRENCY를 2로 증가 → 처리량 2배 증대 -2. Baseline 테스트 재실행하여 안정성 재검증 - -**Phase 2 (중장기)** -1. 이미지 전처리 최적화 (해상도, 압축율) -2. EasyOCR 대신 더 빠른 OCR 엔진 평가 (TrOCR, PaddleOCR) -3. GPU 활용 검토 (GCE GPU 인스턴스) - -**Phase 3 (장기)** -1. 마이크로서비스 아키텍처: OCR 서비스 독립 스케일링 -2. 메시지 브로커 클러스터링 -3. 캐싱 전략 (이미지 해시 기반 캐시 완료 결과) - -## 참고 자료 - -- MQTT 메시지 형식: `/app/docs/mqtt-protocol.md` -- Celery 설정: `/app/celery_config.py` -- Django MQTT Subscriber: `/app/mqtt/subscriber.py` -- OCR 워커 구현: `/app/ocr/tasks.py` -- Grafana 대시보드 설정: `/app/docker/grafana/dashboards/` -- Loki 설정: `/app/docker/loki/loki-config.yaml` - ---- - -**작성일**: 2026년 2월 13일 -**최종 수정**: 2026년 2월 13일 -**유지보수자**: SpeedCam 팀 diff --git a/docs/performance-analysis.md b/docs/performance-analysis.md index 1af7816..e123247 100644 --- a/docs/performance-analysis.md +++ b/docs/performance-analysis.md @@ -107,14 +107,17 @@ graph TB end subgraph Workers["Event Processors"] - OCR["ocr-worker
• 감지 이벤트 처리
• OCR 수행"] - Alert["alert-worker
• 완료 이벤트 처리
• FCM 발송"] + OCR["ocr-worker
• 감지 이벤트 처리
• OCR 수행
• detections.completed 발행"] + subgraph AlertWorker["alert-worker"] + KombuConsumer["Kombu Consumer
(단일 스레드)
domain event 구독"] + CeleryGevent["Celery gevent pool
(concurrency=100)
FCM 병렬 전송"] + end end subgraph MessageBroker["RabbitMQ"] MQTT["MQTT Plugin"] Queue1[("감지 이벤트 큐")] - Queue2[("알림 이벤트 큐")] + DomainEvents[("domain_events exchange
detections.completed")] end subgraph Storage["Google Cloud Storage"] @@ -127,17 +130,20 @@ graph TB Publisher --> Queue1 Queue1 --> OCR OCR -->|"이미지 다운로드"| GCS - OCR --> Queue2 - Queue2 --> Alert + OCR -->|"domain event 발행"| DomainEvents + DomainEvents -->|"choreography"| KombuConsumer + KombuConsumer -->|"send_notification.delay()"| CeleryGevent Main --> DB1[("default")] Main --> DB2[("vehicles_db")] OCR --> DB3[("detections_db")] - Alert --> DB4[("notifications_db")] + AlertWorker --> DB4[("notifications_db")] style Main fill:#90EE90 style OCR fill:#87CEEB - style Alert fill:#DDA0DD + style AlertWorker fill:#DDA0DD + style KombuConsumer fill:#C8A2C8 + style CeleryGevent fill:#DDA0DD style MessageBroker fill:#FFB6C1 style Storage fill:#FFFACD ``` @@ -149,7 +155,9 @@ graph TB | **Edge Device** | 과속 차량 감지 | MQTT | QoS 1, 경량, 영구 연결 | | **main (Django)** | API + MQTT 구독 | HTTP + MQTT | 이벤트 발행만 담당 | | **ocr-worker** | 번호판 OCR 처리 | AMQP | 비동기 처리, concurrency=1 | -| **alert-worker** | FCM 푸시 알림 | AMQP | 고성능, concurrency=100 | +| **alert-worker** | FCM 푸시 알림 | AMQP domain events | choreography 패턴, gevent concurrency=100 | +| **alert-worker (Kombu)** | domain event 구독 | AMQP (domain_events exchange) | 단일 스레드 Kombu Consumer | +| **alert-worker (Celery)** | FCM 병렬 전송 | - | gevent pool, send_notification.delay() | | **RabbitMQ** | 메시지 브로커 | MQTT + AMQP | At-Least-Once 보장 | #### End-to-End 이벤트 흐름 @@ -160,7 +168,8 @@ sequenceDiagram participant RMQ as RabbitMQ participant Main as main participant OCR as ocr-worker - participant Alert as alert-worker + participant Kombu as alert-worker
(Kombu Consumer) + participant Celery as alert-worker
(Celery gevent) participant User as 사용자 앱 Edge->>RMQ: MQTT Publish (과속 차량 감지) @@ -172,10 +181,12 @@ sequenceDiagram RMQ->>OCR: 감지 이벤트 수신 OCR->>OCR: 번호판 OCR 처리 OCR->>OCR: DB 업데이트 (completed) - OCR->>RMQ: OCR 완료 이벤트 발행 + OCR->>RMQ: detections.completed 발행 (domain_events exchange) - RMQ->>Alert: 완료 이벤트 수신 - Alert->>User: FCM Push 알림 + Note over RMQ,Kombu: Choreography 패턴 — Main Service 불개입 + RMQ->>Kombu: domain event 수신 (직접 구독) + Kombu->>Celery: send_notification.delay() + Celery->>User: FCM Push 알림 (gevent 병렬) ``` --- @@ -214,7 +225,7 @@ sequenceDiagram | **speedcam-ocr** | Celery OCR Worker | EasyOCR 처리 (concurrency=1) | | | Promtail | 로그 수집 에이전트 | | | cAdvisor | 컨테이너 메트릭 수집 | -| **speedcam-alert** | Celery Alert Worker | FCM 알림 발송 (concurrency=100) | +| **speedcam-alert** | Kombu Consumer + Celery gevent Worker | FCM 알림 발송 (gevent concurrency=100) | | | Promtail | 로그 수집 에이전트 | | | cAdvisor | 컨테이너 메트릭 수집 | | **speedcam-mon** | Prometheus | 메트릭 수집 | @@ -227,13 +238,15 @@ sequenceDiagram ### 3.3 리소스 사용 현황 +> 재측정 예정 — 아래 수치는 참고용이며 현재 상태와 다를 수 있습니다. + | 인스턴스 | RAM 사용 | RAM 여유 | 메모리 집약적 프로세스 | 비고 | |---------|---------|---------|---------------------|------| | speedcam-app | 661MB/2GB | 1.1GB | Gunicorn 2 workers | 안정적 | | speedcam-db | 853MB/4GB | 2.6GB | MySQL 버퍼풀 | 충분한 여유 | | speedcam-mq | 471MB/2GB | 1.2GB | RabbitMQ | 안정적 | | **speedcam-ocr** | 1.0GB/2GB | 721MB | EasyOCR 모델 (1.5GB) | **메모리 부족 위험** | -| speedcam-alert | 433MB/2GB | 1.3GB | 경량 워커 | 충분한 여유 | +| speedcam-alert | 433MB/2GB | 1.3GB | Kombu Consumer + Celery gevent | 충분한 여유 | | **speedcam-mon** | 1.5GB/2GB | 264MB | Prometheus + Grafana | **메모리 부족 위험** | **주의사항:** @@ -266,8 +279,10 @@ sequenceDiagram | 항목 | 상세 | |------|------| -| **테스트 일시** | 2026-02-12 (k6 4시나리오 + MQTT 3시나리오) | +| **테스트 일시 (v2)** | 2026-02-12 (k6 4시나리오 + MQTT 3시나리오) | +| **테스트 일시 (v1)** | 2026-02-19 14:03:38 ~ 14:18:02 UTC (KST 23:03:38 ~ 23:18:02) (TEST_ID: v1-baseline-1771509818) | | **k6 실행 위치** | speedcam-app 인스턴스 내부 (localhost 호출) | +| **v1 k6 실행 위치** | speedcam-v1-app (10.178.0.8) 인스턴스 내부 (localhost 호출) | | **MQTT 테스트 실행 위치** | speedcam-app → speedcam-mq (내부 IP 10.178.0.7) | | **네트워크 환경** | 동일 VPC (asia-northeast3), 인스턴스 간 지연 <1ms | | **부하 발생기 → 서버 지연** | k6: ~0ms (localhost), MQTT: <1ms (같은 VPC) | @@ -294,13 +309,7 @@ Django REST API의 처리 성능과 응답 시간을 측정하기 위해 4가지 #### 4.3.2 전체 결과 요약 -``` -✅ 총 요청: 2,297건 (평균 6.75 req/s) -✅ 전체 p95 응답시간: 38.85ms -✅ 에러율: 0.21% (5/2,277건) - FCM 토큰 업데이트 엔드포인트 문제 -✅ 모든 임계값(Threshold) 통과 -✅ Prometheus Remote Write → Grafana 메트릭 기록 -``` +테스트 후 기록 #### 4.3.3 시나리오별 상세 결과 @@ -308,68 +317,66 @@ Django REST API의 처리 성능과 응답 시간을 측정하기 위해 4가지 | 메트릭 | avg | min | med | max | p(90) | p(95) | |-------|-----|-----|-----|-----|-------|-------| -| **dashboard_req_duration** | 19.27ms | 9.73ms | 17.65ms | 118.73ms | 26.89ms | 30.6ms | -| **admin_req_duration** | 17.78ms | 4.21ms | 17.82ms | 53.17ms | 21.05ms | 23.23ms | -| **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 | -| **spike_resilience (overall)** | 21.42ms | 8.63ms | 18.79ms | 162.31ms | 31.6ms | 40.54ms | -| **http_req_duration (전체)** | 20.72ms | 3.75ms | 18.23ms | 162.31ms | 30.14ms | 38.85ms | +| **dashboard_req_duration** | - | - | - | - | - | - | +| **admin_req_duration** | - | - | - | - | - | - | +| **detections_list_duration** | - | - | - | - | - | - | +| **statistics_req_duration** | - | - | - | - | - | - | +| **pending_read_duration** | - | - | - | - | - | - | +| **spike_resilience (overall)** | - | - | - | - | - | - | +| **http_req_duration (전체)** | - | - | - | - | - | - | -**📸 [스크린샷 삽입: k6 Grafana 대시보드 - 4 시나리오 응답시간 그래프]** +**[스크린샷: k6 Grafana 대시보드 - 4 시나리오 응답시간 그래프]** **임계치(Threshold) 검증 결과:** | 임계치 | 기준 | 실측 | 판정 | |--------|------|------|------| -| dashboard_req_duration p(95) | < 200ms | **30.6ms** | ✅ PASS | -| detections_list_duration p(95) | < 300ms | **43.29ms** | ✅ PASS | -| statistics_req_duration p(95) | < 500ms | **42.42ms** | ✅ PASS | -| pending_read_duration p(95) | < 500ms | **16.6ms** | ✅ PASS | -| admin_req_duration p(95) | < 300ms | **23.23ms** | ✅ PASS | -| spike_resilience p(95) | < 1500ms | **40.54ms** | ✅ PASS | -| errors (전체) | < 5% | **0.21%** | ✅ PASS | -| errors (dashboard) | < 1% | **0.00%** | ✅ PASS | -| errors (spike) | < 10% | **0.00%** | ✅ PASS | +| dashboard_req_duration p(95) | < 200ms | - | - | +| detections_list_duration p(95) | < 300ms | - | - | +| statistics_req_duration p(95) | < 500ms | - | - | +| pending_read_duration p(95) | < 500ms | - | - | +| admin_req_duration p(95) | < 300ms | - | - | +| spike_resilience p(95) | < 1500ms | - | - | +| errors (전체) | < 5% | - | - | +| errors (dashboard) | < 1% | - | - | +| errors (spike) | < 10% | - | - | **주요 인사이트:** -- **대시보드 폴링 평균 19ms**: 실시간 데이터 조회가 매우 빠름 -- **스파이크 상황(15 VUs)에서도 p95 40ms**: 급격한 트래픽 증가 시에도 안정적 응답 유지 -- **가설 대비 37배 좋은 성능**: 스파이크 가설(p95 < 1500ms) 대비 실측 40ms -- **4 핸들러(Gunicorn 2w×2t)로 15 VUs 충분히 소화**: 실제 포화점은 50+ VUs + +테스트 후 기록 #### 4.3.4 Checks 결과 | Check 항목 | 성공/전체 | 성공률 | 비고 | |----------|----------|-------|------| -| 서버 헬스체크 | 1/1 | **100%** | ✅ | -| 차량 등록 (201) | ✅ | **100%** | ✅ admin_ops + mixed 시나리오 | -| FCM 토큰 업데이트 (200) | 0/5 | **0%** | ❌ PATCH 엔드포인트 호환 문제 | -| 감지 목록 (200) | ✅ | **100%** | ✅ dashboard + spike 시나리오 | -| 알림 목록 (200) | ✅ | **100%** | ✅ dashboard 시나리오 | -| 통계 조회 (200) | ✅ | **100%** | ✅ dashboard + spike 시나리오 | -| 대기 목록 (200) | ✅ | **100%** | ✅ mixed 시나리오 | -| 혼합 읽기 (200) | ✅ | **100%** | ✅ mixed 시나리오 | -| 혼합 차량 등록 (201) | ✅ | **100%** | ✅ mixed 시나리오 | -| 스파이크 감지 목록 | ✅ | **100%** | ✅ | -| 스파이크 알림 목록 | ✅ | **100%** | ✅ | -| 스파이크 통계 | ✅ | **100%** | ✅ | - -> 전체: 2,272/2,277 checks 성공 (99.78%). 실패 5건은 모두 FCM 토큰 업데이트 PATCH 엔드포인트. +| 서버 헬스체크 | - | - | - | +| 차량 등록 (201) | - | - | admin_ops + mixed 시나리오 | +| FCM 토큰 업데이트 (200) | - | - | PATCH 엔드포인트 | +| 감지 목록 (200) | - | - | dashboard + spike 시나리오 | +| 알림 목록 (200) | - | - | dashboard 시나리오 | +| 통계 조회 (200) | - | - | dashboard + spike 시나리오 | +| 대기 목록 (200) | - | - | mixed 시나리오 | +| 혼합 읽기 (200) | - | - | mixed 시나리오 | +| 혼합 차량 등록 (201) | - | - | mixed 시나리오 | +| 스파이크 감지 목록 | - | - | - | +| 스파이크 알림 목록 | - | - | - | +| 스파이크 통계 | - | - | - | + +> 테스트 후 기록 #### 4.3.5 HTTP API 최대 TPS 분석 | 항목 | 값 | 근거 | |------|-----|------| | **현재 설정** | GUNICORN_WORKERS=2 (각 2 threads = 총 4 HTTP handlers) | 배포 환경 (env.example 기본값=4와 다름) | -| **4시나리오 테스트** | 15 VUs에서 p95=40.54ms, 에러율 0% | k6 4시나리오 실측 | -| **스트레스 테스트** | 50 VUs에서 p95=2,230ms, 에러율 1.5% | k6 stress_ramp 실측 | -| **포화점** | **30~50 VUs 사이** | 15 VUs(정상) → 50 VUs(성능 저하) | -| **안정 최대 TPS** | **~25 req/s** (50 VUs, e2-small에서 k6+서버 공유 시) | 스트레스 테스트 실측 | +| **4시나리오 테스트** | - | k6 4시나리오 실측 후 기록 | +| **스트레스 테스트** | - | k6 stress_ramp 실측 후 기록 | +| **포화점** | - | 재측정 예정 | +| **안정 최대 TPS** | - | 재측정 예정 | | **이론 최대 TPS** | **~80-100 req/s** | 4 handlers × 평균 20ms 기준 | | **주요 병목** | Gunicorn 핸들러 포화 + DB 커넥션 (CONN_MAX_AGE 미설정) | 스트레스 테스트 분석 | -> **측정 근거:** 4시나리오 테스트(가설 기반)에서 15 VUs까지 정상, 스트레스 테스트(50 VUs)에서 포화 확인. 실측 안정 TPS ~25 req/s는 k6가 동일 인스턴스에서 실행된 결과이므로 별도 클라이언트 사용 시 더 높을 수 있음. +> **측정 근거:** 테스트 수행 후 기록 예정 **확장 방법:** 1. `CONN_MAX_AGE` 설정으로 커넥션 풀링 활성화 @@ -391,51 +398,47 @@ Django REST API의 처리 성능과 응답 시간을 측정하기 위해 4가지 | **Phase 1** | stress_ramp (읽기 전용) | 0→10→30→50→0 | 3분30초 | GET 읽기 100% | | **Phase 2** | stress_mixed (혼합) | 0→10→30→50→0 | 3분 | 읽기 80% + 쓰기 20% | +> v1에도 동일한 스트레스 테스트가 추가되었습니다 (`depoly-v1/k6/load-test-v1.js`의 stress_ramp, stress_mixed 시나리오). +> v1 스트레스 결과와 비교하면 아키텍처 전환의 한계점 차이를 확인할 수 있습니다. +> **핵심 비교:** v1 stress_mixed의 20% 동기 OCR 쓰기 vs v2 stress_mixed의 20% 차량등록 쓰기 + **전체 결과** (Prometheus Remote Write 활성, Grafana 메트릭 기록됨) -``` -총 요청: 10,525건 (평균 25.1 req/s) -에러율: 1.50% (158건 실패) -p95 응답시간: 2,230ms -최대 응답시간: 4,260ms -``` +테스트 후 기록 **응답 시간 분포** | 메트릭 | avg | med | p(90) | p(95) | max | |-------|-----|-----|-------|-------|-----| -| **전체 (req_duration)** | 790ms | 742ms | 1,770ms | 2,230ms | 4,260ms | -| **읽기 (read_latency)** | 800ms | 751ms | 1,770ms | 2,230ms | 4,260ms | -| **쓰기 (write_latency)** | 685ms | 526ms | 1,730ms | 2,020ms | 2,790ms | +| **전체 (req_duration)** | - | - | - | - | - | +| **읽기 (read_latency)** | - | - | - | - | - | +| **쓰기 (write_latency)** | - | - | - | - | - | **Phase별 에러율** | Phase | Check | 성공률 | 실패율 | |-------|-------|-------|-------| -| **stress_ramp** (50 VUs, 읽기) | status is 200 | **97%** | 3% | -| **stress_mixed** (50 VUs, 읽기) | read 200 | **99%** | 1% | -| **stress_mixed** (50 VUs, 쓰기) | write 201 | **99%** | 1% | +| **stress_ramp** (50 VUs, 읽기) | status is 200 | - | - | +| **stress_mixed** (50 VUs, 읽기) | read 200 | - | - | +| **stress_mixed** (50 VUs, 쓰기) | write 201 | - | - | -> **테스트 환경 영향 참고:** k6와 Prometheus Remote Write가 동일 인스턴스(e2-small, 2 vCPU)에서 실행되어, k6의 요청 생성 속도가 제한됩니다 (54 req/s → 25 req/s). 이로 인해 서버에 실제 도달하는 부하가 줄어 에러율은 낮아지나, 시스템 전체 리소스 경합으로 응답 시간(p95)은 증가합니다. +> **테스트 환경 영향 참고:** k6와 Prometheus Remote Write가 동일 인스턴스(e2-small, 2 vCPU)에서 실행되어, k6의 요청 생성 속도가 제한됩니다. 이로 인해 서버에 실제 도달하는 부하가 줄어 에러율은 낮아지나, 시스템 전체 리소스 경합으로 응답 시간(p95)은 증가합니다. -**📸 [스크린샷 삽입: k6 Grafana 대시보드 - VUs 변화에 따른 응답시간/에러율 그래프]** +**[스크린샷: k6 Grafana 대시보드 - VUs 변화에 따른 응답시간/에러율 그래프]** -**📸 [스크린샷 삽입: Container Metrics - speedcam-app의 CPU/Memory 그래프 (스트레스 테스트 구간)]** +**[스크린샷: Container Metrics - speedcam-app의 CPU/Memory 그래프 (스트레스 테스트 구간)]** **부하 수준별 성능 비교 (실측)** | VUs | 시나리오 | p95 | 에러율 | 처리량 | 판정 | |-----|---------|-----|-------|-------|------| -| **15** | spike_resilience | **49ms** | **0%** | 6.7 req/s | ✅ 정상 | -| **50** | stress_ramp | **2,230ms** | **3%** | 25.1 req/s | ⚠️ 성능 저하 | -| **50** | stress_mixed | **2,020ms** | **1%** | 25.1 req/s | ⚠️ 성능 저하 | +| **15** | spike_resilience | - | - | - | - | +| **50** | stress_ramp | - | - | - | - | +| **50** | stress_mixed | - | - | - | - | **핵심 발견:** -- **15 VUs → 50 VUs**: p95가 49ms에서 2,230ms로 **45배 악화** -- 50 VUs에서 median=742ms → 대부분의 요청이 700ms 이상 소요 (15 VUs에서 17ms 대비 **43배**) -- 에러율은 1.5%로 서비스 가용 범위이나, **응답 시간 저하가 심각** (SLA 기준 위반 가능) -- 쓰기(POST)가 읽기(GET) 대비 med 기준 ~30% 빠름 (526ms vs 751ms) — DB 읽기가 쓰기보다 무거운 패턴 -- **e2-small에서 k6+서버 동시 실행의 한계**: 별도 부하 발생기 인스턴스 사용 시 더 정확한 측정 가능 + +테스트 후 기록 --- @@ -475,16 +478,16 @@ Stage 3: OCR 처리 (GCS 다운로드 + EasyOCR 추론) | Detection ID | MQTT 수신 시각 | Detection 생성 | OCR 디스패치 | 총 Subscriber 처리 시간 | |-------------|--------------|---------------|-------------|---------------------| -| #3284 | 01:19:00.489 | 01:19:00.554 | 01:19:00.560 | **71ms** | -| #3285 | 01:49:54.207 | 01:49:54.222 | 01:49:54.229 | **22ms** | -| #3286 | 01:56:37.918 | 01:56:37.925 | 01:56:37.928 | **10ms** | -| #3287 | 02:09:11.080 | 02:09:11.091 | 02:09:11.097 | **17ms** | -| #3288 | 02:15:44.137 | 02:15:44.145 | 02:15:44.148 | **11ms** | +| - | - | - | - | - | +| - | - | - | - | - | +| - | - | - | - | - | +| - | - | - | - | - | +| - | - | - | - | - | -**평균 Subscriber 처리 시간: 15ms** (Cold Start #3284 제외) +**평균 Subscriber 처리 시간:** 테스트 후 기록 - JSON 파싱 + DB Insert + AMQP Publish 포함 -- #3284의 71ms는 첫 요청 시 DB 커넥션 수립 시간이 포함된 이상값 (이후 안정화) +- Cold Start 시 DB 커넥션 수립 시간 포함으로 이상값 발생 가능 (이후 안정화) --- @@ -492,11 +495,11 @@ Stage 3: OCR 처리 (GCS 다운로드 + EasyOCR 추론) | Detection ID | 디스패치 시각 | Worker 수신 시각 | AMQP 전달 시간 | |-------------|-------------|----------------|--------------| -| #3284 | 01:19:00.560 | 01:19:00.563 | **3ms** | -| #3285 | 01:49:54.229 | 01:49:54.230 | **1ms** | -| #3286 | 01:56:37.928 | 01:56:37.935 | **7ms** | +| - | - | - | - | +| - | - | - | - | +| - | - | - | - | -**평균 AMQP 전달 시간: ~3ms** +**평균 AMQP 전달 시간:** 테스트 후 기록 - RabbitMQ 내부 라우팅 오버헤드 매우 낮음 @@ -506,26 +509,25 @@ Stage 3: OCR 처리 (GCS 다운로드 + EasyOCR 추론) | Detection ID | 이미지 | OCR 처리 시간 | 인식 결과 | 신뢰도 | 비고 | |-------------|--------|-------------|----------|--------|------| -| #3284 | test-plate-1.jpg (흰 이미지) | **35.59s** | None | 0% | Cold Start (모델 로딩 포함) | -| #3285 | plate-01.jpg (자동차 배경) | **8.39s** | None | 0% | Warm, 배경 노이즈로 인식 실패 | -| #3286 | real-plate-01.jpg (고대비) | **5.15s** | 12가3456 | **72.1%** | ✅ 정상 인식 | -| #3287 | real-plate-02.jpg (고대비) | **5.11s** | 34나5678 | **86.8%** | ✅ 정상 인식 | -| #3288 | real-plate-03.jpg (고대비) | **5.02s** | 56다7890 | **98.8%** | ✅ 정상 인식 | +| - | - | - | - | - | - | +| - | - | - | - | - | - | +| - | - | - | - | - | - | +| - | - | - | - | - | - | +| - | - | - | - | - | - | **OCR 성능 요약:** | 지표 | 값 | |------|-----| -| **Cold Start (모델 로딩 포함)** | ~35s | -| **Warm OCR 평균** | **~5.1s** (GCS 다운로드 ~0.5s + EasyOCR 추론 ~4.6s) | -| **OCR 최대 TPS** | **~0.2 msg/s** (1 worker, concurrency=1) | -| **고대비 한국어 번호판 인식률** | **100%** (3/3) | -| **평균 신뢰도** | **85.9%** | +| **Cold Start (모델 로딩 포함)** | 재측정 예정 | +| **Warm OCR 평균** | 재측정 예정 | +| **OCR 최대 TPS** | 재측정 예정 | +| **고대비 한국어 번호판 인식률** | 재측정 예정 | +| **평균 신뢰도** | 재측정 예정 | **주요 인사이트:** -- 고대비 한국어 번호판 이미지에서 OCR 인식률 100% -- 배경 노이즈가 있는 이미지는 인식 실패 (전처리 필요) -- Warm 상태 OCR 처리 시간 5.1s는 단일 워커 기준으로 적절 + +테스트 후 기록 --- @@ -535,27 +537,29 @@ Stage 3: OCR 처리 (GCS 다운로드 + EasyOCR 추론) ``` Edge Device - ↓ MQTT Publish (~50ms network) + ↓ MQTT Publish (network) RabbitMQ MQTT Plugin - ↓ Internal routing (~1ms) + ↓ Internal routing Django Subscriber (MQTT → DB → AMQP) - ↓ ~15ms (JSON parse + DB insert + AMQP publish) + ↓ (JSON parse + DB insert + AMQP publish) RabbitMQ AMQP Queue - ↓ ~3ms (queue routing) + ↓ (queue routing) OCR Worker - ↓ ~5,100ms (GCS download + EasyOCR inference) + ↓ (GCS download + EasyOCR inference) DB Update (completed) - ↓ ~10ms -Alert Queue → FCM Notification - ↓ (FCM 미구현 상태) - -Total E2E: ~5,200ms (warm) / ~35,700ms (cold start) + ↓ +RabbitMQ domain_events exchange (detections.completed) + ↓ Choreography +Alert Worker Kombu Consumer + ↓ send_notification.delay() +Alert Worker Celery gevent pool → FCM Notification + +Total E2E: 재측정 예정 ``` **병목 지점:** -- **OCR Worker (5.1s)**: 전체 파이프라인의 98% 차지 -- GCS 다운로드: ~0.5s -- EasyOCR 추론: ~4.6s +- **OCR Worker**: 전체 파이프라인의 지배적 병목 (GCS 다운로드 + EasyOCR 추론) +- 구체적 수치는 재측정 후 기록 **개선 방안:** 1. **GPU 인스턴스 전환**: CPU → GPU로 OCR 추론 시간 단축 (5s → <1s 목표) @@ -586,11 +590,11 @@ Total E2E: ~5,200ms (warm) / ~35,700ms (cold start) | 시나리오 | 발행 성공 | 발행 실패 | 평균 발행 지연 | 실측 발행 속도 | |---------|----------|----------|-------------|-------------| -| **Normal** | 40/40 (100%) | 0건 | 0.91ms | 0.33 msg/s | -| **Rush Hour** | 200/200 (100%) | 0건 | 0.38ms | 1.66 msg/s | -| **Burst** | 1,200/1,200 (100%) | 0건 | 0.37ms | 19.96 msg/s | +| **Normal** | - | - | - | - | +| **Rush Hour** | - | - | - | - | +| **Burst** | - | - | - | - | -> 전 시나리오에서 MQTT 발행 100% 성공. RabbitMQ가 20 msg/s까지 안정적으로 수용. +> 테스트 후 기록 --- @@ -598,13 +602,13 @@ Total E2E: ~5,200ms (warm) / ~35,700ms (cold start) | 지표 | Normal 가설 | Normal 실측 | Rush Hour 가설 | Rush Hour 실측 | Burst 가설 | Burst 실측 | |------|-----------|-----------|--------------|--------------|-----------|-----------| -| **발행 성공률** | 100% | **100%** ✅ | 100% | **100%** ✅ | 100% | **100%** ✅ | -| **완료율 (300s)** | 100% | **80% (32/40)** ❌ | 95% | **11% (22/200)** ❌ | 100% (drain) | **1.5% (18/1200)** ❌ | -| **E2E 완료 시간** | 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** ✅ | +| **발행 성공률** | 100% | - | 100% | - | 100% | - | +| **완료율 (300s)** | 100% | - | 95% | - | 100% (drain) | - | +| **E2E 완료 시간** | 60초 | - | 120초 | - | 300초 | - | +| **OCR 큐 피크** | < 5 | - | < 50 | - | 200-500 | - | +| **DLQ 메시지** | 0 | - | 0 | - | 0 | - | -> 가설은 OCR_MOCK=true 기준으로 작성. 실제 EasyOCR 환경에서는 OCR 처리 속도가 **133~667배** 느림. +> 가설은 OCR_MOCK=true 기준으로 작성. 실제 EasyOCR 환경에서의 실측값은 테스트 후 기록. --- @@ -613,13 +617,13 @@ Total E2E: ~5,200ms (warm) / ~35,700ms (cold start) ``` 시간(s) 완료 대기 OCR큐 FCM큐 실효 처리속도 ────────────────────────────────────────────── - 10 16 24 24 0 - - 50 19 21 22 0 0.075 msg/s - 100 21 19 19 0 0.040 msg/s - 150 24 16 16 0 0.060 msg/s - 200 26 14 14 0 0.040 msg/s - 250 29 11 11 0 0.060 msg/s - 300 32 8 9 0 0.060 msg/s (타임아웃) + 10 - - - - - + 50 - - - - - + 100 - - - - - + 150 - - - - - + 200 - - - - - + 250 - - - - - + 300 - - - - - ``` **Rush Hour 시나리오 - OCR 큐 드레인 추이** @@ -627,12 +631,12 @@ Total E2E: ~5,200ms (warm) / ~35,700ms (cold start) ``` 시간(s) 완료 대기 OCR큐 FCM큐 ────────────────────────────────── - 10 7 193 201 0 ← 발행 직후 큐 폭주 - 60 10 190 198 0 - 120 13 187 195 0 - 180 16 184 193 0 - 240 19 181 189 0 - 300 22 178 186 0 ← 타임아웃, 178건 미처리 + 10 - - - - + 60 - - - - + 120 - - - - + 180 - - - - + 240 - - - - + 300 - - - - ``` **Burst 시나리오 - OCR 큐 드레인 추이** @@ -640,19 +644,19 @@ Total E2E: ~5,200ms (warm) / ~35,700ms (cold start) ``` 시간(s) 완료 대기 OCR큐 FCM큐 ────────────────────────────────────── - 10 3 1197 1,381 0 ← 1,200건 + 기존 백로그 - 60 6 1194 1,378 0 - 120 9 1191 1,375 0 - 180 12 1188 1,372 0 - 240 15 1185 1,369 0 - 300 18 1182 1,366 0 ← 타임아웃, 1,182건 미처리 + 10 - - - - + 60 - - - - + 120 - - - - + 180 - - - - + 240 - - - - + 300 - - - - ``` -**📸 [스크린샷 삽입: RabbitMQ 대시보드 - OCR 큐 깊이 변화 (3 시나리오 전체 구간)]** +**[스크린샷: RabbitMQ 대시보드 - OCR 큐 깊이 변화 (3 시나리오 전체 구간)]** -**📸 [스크린샷 삽입: Celery Workers 대시보드 - OCR Task 처리 속도 (테스트 구간)]** +**[스크린샷: Celery Workers 대시보드 - OCR Task 처리 속도 (테스트 구간)]** -**📸 [스크린샷 삽입: Container Metrics - speedcam-ocr CPU/Memory (테스트 구간)]** +**[스크린샷: Container Metrics - speedcam-ocr CPU/Memory (테스트 구간)]** --- @@ -660,18 +664,203 @@ Total E2E: ~5,200ms (warm) / ~35,700ms (cold start) | 지표 | 단건 (4.4.2) | Normal | Rush Hour | Burst | |------|-------------|--------|-----------|-------| -| OCR 처리 속도 | **0.2 msg/s** (5.1s/건) | **0.053 msg/s** (18.8s/건) | **0.073 msg/s** (13.7s/건) | **0.060 msg/s** (16.7s/건) | -| OCR 큐 피크 | 0 | **25** | **202** | **1,381** | -| 파이프라인 완료율 | 100% | **80%** | **11%** | **1.5%** | -| 부하 시 성능 저하 | - | **3.7배** | **2.7배** | **3.3배** | +| OCR 처리 속도 | - | - | - | - | +| OCR 큐 피크 | - | - | - | - | +| 파이프라인 완료율 | - | - | - | - | +| 부하 시 성능 저하 | - | - | - | - | **동시 부하 시 OCR 처리 속도 저하 원인 분석:** -1. **메모리 압박**: e2-small(2GB)에서 EasyOCR 모델(1.5GB) + 큐 버퍼 → 721MB 여유분 소진 +1. **메모리 압박**: e2-small(2GB)에서 EasyOCR 모델(1.5GB) + 큐 버퍼 → 메모리 여유분 소진 2. **GCS 다운로드 경합**: 연속 다운로드 시 네트워크/API 지연 증가 3. **CPU 경합**: OCR 추론 중 Celery 큐 관리 오버헤드 -4. **큐 백로그 누적**: Rush Hour/Burst 후 큐 드레인에 수 시간 소요 (Burst 후 잔여 1,362건 → 약 6.3시간) +4. **큐 백로그 누적**: Rush Hour/Burst 후 큐 드레인에 수 시간 소요 + +> **결론:** OCR Worker가 전체 파이프라인의 지배적 병목. **OCR Worker 확장(수평 또는 GPU 전환)은 선택이 아닌 필수입니다.** + +--- + +## 4.5 v1 부하 테스트 결과 (Before — 동기 OCR 아키텍처) + +### 4.5.1 테스트 메타데이터 + +| 항목 | 상세 | +|------|------| +| **테스트 일시** | 2026-02-19 14:03:38 ~ 14:18:02 UTC (KST 23:03:38 ~ 23:18:02) | +| **TEST_ID** | v1-baseline-1771509818 | +| **인스턴스** | speedcam-v1-app (10.178.0.8) | +| **모니터링** | Prometheus / Grafana at 10.178.0.9 / 10.178.0.9:3000 | +| **총 소요 시간** | 14분 24.1초 | +| **시나리오 수** | 6개 | +| **최대 VUs** | 50 | +| **종료 코드** | 99 (임계치 위반 3건 — 정상 범위) | + +--- + +> **측정 도구별 수치 참고:** v1 OCR 응답시간 수치는 **Grafana Django Application Metrics 대시보드**에서 관측된 값(서버 측 histogram 기반)을 사용합니다. k6 커스텀 메트릭 원본 값(avg 12.17s, p95 20.83s)과 차이가 있으며, 이는 Django histogram 버킷 보간과 측정 범위 차이 때문입니다. + +### 4.5.2 임계치(Threshold) 검증 결과 + +> **임계치 설정 기준:** v1 임계치는 "이 정도면 합격"이라는 품질 기준이 아니라, **v1 동기 OCR 아키텍처의 한계를 정량적으로 드러내기 위한 측정 기준**입니다. 특히 스트레스 시나리오의 기준은 의도적으로 관대하게 설정하여, 관대한 기준조차 통과하지 못하는 항목이 곧 아키텍처 전환이 필요한 근거가 됩니다. + +| 임계치 | 기준 | 실측 | 판정 | 기준 설정 근거 | +|--------|------|------|------|--------------| +| 차량 목록 조회 p95 | < 300ms | 215.87ms | PASS | 단순 DB 페이지네이션 조회 | +| 대시보드 응답 p95 | < 200ms | 2.66s | FAIL | 순수 읽기, OCR 없는 정상 응답 기대 | +| 전체 에러율 | < 5% | 0.55% | PASS | 전체 요청 대비 허용 실패율 | +| 에러율 (대시보드 폴링) | < 1% | 0.00% | PASS | 읽기 전용, 실패 불허 | +| 에러율 (스파이크) | < 10% | 0.00% | PASS | 15 VUs 급증 시 큐잉 타임아웃 허용 | +| 에러율 (스트레스 혼합) | < 30% | 19.51% | PASS | 50 VUs + OCR → 시스템 붕괴 관측 목적 | +| 에러율 (스트레스 읽기) | < 20% | 0.00% | PASS | 50 VUs 읽기 과부하 허용 | +| 에러율 (동기 OCR) | < 20% | 0.00% | PASS | 실제 이미지 OCR, 네트워크 실패만 허용 | +| 스파이크 응답 p95 | < 2s | 128.06ms | PASS | 15 VUs 큐잉 포함 | +| 동기 OCR 응답 p95 | < 10s | 24.2s | FAIL | EasyOCR CPU 추론 ~3s 기준, 관대하게 10s | +| 스트레스 읽기 p95 | < 5s | 337.41ms | PASS | 50 VUs 극한 큐잉 허용 | +| 스트레스 쓰기 p95 | < 30s | 30s | FAIL | k6 요청 타임아웃 상한 = 사실상 "타임아웃 전 완료" 기준 | + +**FAIL 분석:** + +| FAIL 항목 | 원인 | 의미 | +|-----------|------|------| +| 대시보드 응답 p95 (2.66s > 200ms) | OCR이 HTTP 스레드를 점유하면 읽기 요청도 대기 | **OCR 부하가 읽기 API에 전파되는 구조적 문제** | +| 동기 OCR 응답 p95 (24.2s > 10s) | GCS 다운로드 + EasyOCR 추론이 HTTP 스레드에서 동기 실행 | **관대한 10s 기준도 2.4배 초과** | +| 스트레스 쓰기 p95 (30s = 30s) | 50 VUs 혼합 부하에서 OCR 요청이 타임아웃 상한에 도달 | **타임아웃까지 허용해도 FAIL → 사실상 처리 불가** | + +--- + +### 4.5.3 커스텀 메트릭 응답 시간 + +| 메트릭 | avg | min | med | max | p90 | p95 | +|--------|-----|-----|-----|-----|-----|-----| +| 차량 목록 조회 | 160.1ms | 9.87ms | 27.71ms | 11.64s | 91.73ms | 215.87ms | +| 대시보드 응답 | 402.28ms | 9.87ms | 23.56ms | 11.64s | 133.26ms | 2.66s | +| 동기 OCR 응답 | 15.1s | 6.16s | 15.4s | 25s | 20s | 24.2s | +| 스트레스 읽기 | 837.57ms | 9.01ms | 22.41ms | 60s | 97.43ms | 337.41ms | +| 스트레스 쓰기 (OCR) | 26.93s | 13.27s | 30s | 30s | 30s | 30s | +| 미확인 목록 조회 | 127.66ms | 15.36ms | 30.62ms | 12.18s | 105.8ms | 149.37ms | + +> stress_write_duration avg=26.93s, med=30s — 대부분의 OCR 동기 쓰기 요청이 타임아웃 상한(30s)에 도달했음을 나타냅니다. + +--- + +### 4.5.4 시나리오별 상세 분석 + +#### A. dashboard_polling — 대시보드 폴링 + +- **결과:** 에러율 0% (0/108), 정상 완료 +- **응답 시간:** dashboard_req_duration p95=2.66s (임계치 200ms 초과 FAIL) +- **인사이트:** VUs=3의 낮은 부하에서도 p95가 2.66s에 달했습니다. 대다수 요청의 med=23.56ms임을 감안하면 일부 요청이 OCR 처리 중인 HTTP 스레드 대기로 인해 극단적인 응답 지연을 겪었음을 나타냅니다. 동기 OCR이 HTTP 스레드를 점유하는 구조적 문제가 폴링 요청에도 직접 영향을 미쳤습니다. + +> **[캡처 A-1]** k6 Prometheus Dashboard → HTTP Request Duration 패널 +> - Grafana URL: http://10.178.0.9:3000 +> - 시간 범위: 2026-02-19 23:03:00 ~ 23:19:00 KST +> - 확인 포인트: dashboard_polling 구간(초기 2분) p95 응답시간 분포, OCR 요청 발생 시 폴링 응답시간 급등 여부 + +--- + +#### B. sync_ocr_stress — 동기 OCR 핵심 병목 + +- **결과:** 에러율 0% (0/12), 정상 완료 +- **응답 시간:** OCR POST avg=15.1s, p95=24.2s (임계치 10,000ms FAIL) +- **인사이트:** HTTP 스레드에서 동기적으로 EasyOCR을 실행하는 구조에서 단 12건의 OCR 요청만으로도 평균 15.1초가 소요되었습니다. OCR이 HTTP 스레드를 점유하는 동안 다른 모든 요청이 큐잉되어 대기합니다. 요청 수가 적어 에러율 0%를 달성했지만, 처리 시간 자체가 임계치를 2.4배 초과하는 병목을 확인했습니다. + +> **[캡처 B-1]** k6 Prometheus Dashboard → ocr_req_duration 패널 +> - Grafana URL: http://10.178.0.9:3000 +> - 시간 범위: 2026-02-19 23:03:00 ~ 23:19:00 KST +> - 확인 포인트: sync_ocr_stress 구간의 OCR 응답시간 분포, avg 15.1s / p95 24.2s 확인 + +> **[캡처 B-2]** Container Metrics → speedcam-v1-app CPU 사용률 +> - Grafana URL: http://10.178.0.9:3000 +> - 시간 범위: 2026-02-19 23:03:00 ~ 23:19:00 KST +> - 확인 포인트: OCR 동기 처리 구간의 CPU 점유율, Gunicorn 스레드 포화 여부 + +--- + +#### C. mixed_workload — 혼합 워크로드 + +- **결과:** 전체 에러율 0.55% (40/7248)의 대부분이 stress_mixed에 집중 +- **인사이트:** 읽기와 OCR 쓰기가 혼합된 환경에서 OCR 동기 처리가 읽기 요청의 응답시간에도 영향을 미쳤습니다. 별도 커스텀 메트릭이 없어 전체 http_req_duration 기준으로 평가됩니다. + +--- + +#### D. spike_resilience — 스파이크 내성 + +- **결과:** 에러율 0% (0/1731), 완전 성공 +- **응답 시간:** p95=128.06ms, avg=51.31ms +- **인사이트:** 읽기 전용 스파이크(0→15 VUs)에서는 우수한 성능을 보였습니다. OCR 요청이 없는 순수 읽기 부하에서는 v1 아키텍처도 안정적으로 동작합니다. 이는 OCR 동기 처리가 정확히 병목임을 역설적으로 증명합니다. + +> **[캡처 D-1]** k6 Prometheus Dashboard → spike_resilience 구간 VUs 및 응답시간 +> - Grafana URL: http://10.178.0.9:3000 +> - 시간 범위: 2026-02-19 23:03:00 ~ 23:19:00 KST +> - 확인 포인트: VUs 급증 시 응답시간 변화, p95=128.06ms 확인 + +--- + +#### E. stress_ramp — 50 VUs 읽기 전용 스트레스 + +- **결과:** 에러율 0% (0/5022), 완전 성공 +- **응답 시간:** stress_read_duration p95=337.41ms (임계치 5,000ms PASS) +- **인사이트:** 읽기 전용 요청에서 50 VUs까지 에러 없이 처리했습니다. stress_read_duration의 max=60s는 타임아웃 발생을 나타내지만 p95=337ms로 대부분 정상 처리되었습니다. OCR이 개입하지 않으면 v1도 50 VUs 읽기 부하를 수용할 수 있음을 확인했습니다. + +> **[캡처 E-1]** k6 Prometheus Dashboard → stress_ramp 구간 응답시간 및 VUs +> - Grafana URL: http://10.178.0.9:3000 +> - 시간 범위: 2026-02-19 23:03:00 ~ 23:19:00 KST +> - 확인 포인트: 0→50 VUs 램프업 구간의 응답시간 추이, stress_read_duration p95=337ms 확인 + +--- + +#### F. stress_mixed — 50 VUs 혼합 (핵심 발견) + +- **결과:** 에러율 19.51% (40/205) — 이 테스트의 핵심 발견 +- **응답 시간:** stress_write_duration p95=30s, avg=26.93s (임계치 30,000ms FAIL) +- **체크 성공률:** + - 스트레스 혼합 읽기 200: 91% (155/170) — 9% fail + - 스트레스 혼합 OCR POST 성공: **28% (10/35)** — OCR 쓰기 요청 72% 실패 +- **인사이트:** 50 VUs에서 80% 읽기 + 20% OCR 쓰기가 혼합될 때 시스템이 사실상 붕괴합니다. OCR POST 성공률 28%는 동기 OCR이 HTTP 스레드를 점유하여 4개의 Gunicorn 스레드가 포화 상태가 됨으로써 나머지 요청이 모두 타임아웃되는 구조적 한계를 보여줍니다. 이 데이터가 v2 비동기 EDA 전환의 핵심 근거입니다. + +> **[캡처 F-1]** k6 Prometheus Dashboard → stress_mixed 구간 에러율 및 응답시간 +> - Grafana URL: http://10.178.0.9:3000 +> - 시간 범위: 2026-02-19 23:03:00 ~ 23:19:00 KST +> - 확인 포인트: stress_mixed 구간 에러율 급등(19.51%), OCR 쓰기 응답시간 30s 도달, 읽기 응답시간 동반 상승 여부 + +> **[캡처 F-2]** Container Metrics → speedcam-v1-app CPU / Memory +> - Grafana URL: http://10.178.0.9:3000 +> - 시간 범위: 2026-02-19 23:03:00 ~ 23:19:00 KST +> - 확인 포인트: stress_mixed 구간 CPU 포화, 메모리 압박, Gunicorn 스레드 포화 지표 + +**Django Application Metrics NaN 공백 — 서버 측 병목 증거:** + +stress_mixed 구간에서 k6 OCR POST Response Time 패널은 연속 데이터(30s)가 존재하지만, Django OCR POST Latency 패널에는 약 1분간 NaN 공백이 발생합니다. k6는 클라이언트 측 timeout을 기록하지만, Django histogram은 응답 완료 시에만 counter가 증가하기 때문입니다. 50 VU 혼합 부하에서 4개 Gunicorn 스레드가 전부 OCR에 점유되면 **새 응답 완료가 없는 구간**이 발생하고, `rate(histogram[5m])=0` → `histogram_quantile=NaN`이 됩니다. 이 NaN 공백 자체가 v1 동기 처리 병목의 직접적 증거이며, 대시보드 폴링 p95가 2.66s(임계치 200ms의 13배)로 치솟는 현상과 같은 근본 원인입니다. + +--- + +### 4.5.5 전체 HTTP 요약 -> **결론:** 가장 낙관적인 시나리오(Normal, 0.33 msg/s)에서도 OCR Worker가 처리를 따라가지 못합니다. **OCR Worker 확장(수평 또는 GPU 전환)은 선택이 아닌 필수입니다.** +| 항목 | 값 | +|------|-----| +| **총 요청 수** | 7,248건 | +| **전체 처리량** | 8.39 req/s | +| **http_req_failed** | 0.55% (40/7,248) | +| **http_req_duration avg** | 811.67ms | +| **http_req_duration med** | 23.31ms | +| **http_req_duration p90** | 102.26ms | +| **http_req_duration p95** | 351.21ms | +| **http_req_duration max** | 60s | +| **전체 iterations** | 6,056 완료 / 6 중단 | +| **checks 성공률** | 99.44% (7,220/7,260) | + +--- + +### 4.5.6 핵심 발견 + +- **동기 OCR이 전체 시스템 병목:** OCR 처리(avg 15.1s)가 Gunicorn HTTP 스레드를 점유하여 스레드 수(4개) 이상의 동시 OCR 요청 시 시스템 전체가 응답 불가 상태로 전락합니다. +- **stress_mixed에서 OCR POST 성공률 28%:** 50 VUs 혼합 부하에서 OCR 쓰기의 72%가 실패합니다. 읽기 요청도 동반 영향을 받아 스트레스 읽기 에러 9%(155/170)가 발생했습니다. +- **순수 읽기 부하는 안정적:** spike_resilience(15 VUs, p95=128.06ms), stress_ramp(50 VUs, p95=337.41ms) 모두 에러율 0%로 OCR이 없으면 v1도 충분한 읽기 성능을 보입니다. +- **v2 비동기 EDA 전환의 정량적 근거 확보:** 동기 OCR POST 성공률 28% vs v2의 차량등록(OCR 분리) 쓰기 성공률 비교로 아키텍처 전환 효과를 정량화할 수 있습니다. + +> **[캡처 G-1]** Grafana → k6 Prometheus Dashboard 전체 뷰 (14분 테스트 전 구간) +> - Grafana URL: http://10.178.0.9:3000 +> - 시간 범위: 2026-02-19 23:03:00 ~ 23:19:00 KST +> - 확인 포인트: 6개 시나리오 전환 시점, stress_mixed 구간의 응답시간 및 에러율 급등, 전체 VU 추이 --- @@ -681,19 +870,75 @@ Event Driven Architecture 전환을 통해 기존 모놀리식 구조의 모든 ### 5.1 성능 비교 -| 항목 | Before (동기 HTTP) | After (Event Driven) | 개선율 | 측정 근거 | -|-----|-------------------|---------------------|--------|----------| -| **이벤트 처리 시간 (수신~디스패치)** | 3,000ms+ | **15ms** | **200배 빠름** | Before: 구조 추정 / After: 실측 (n=4) | -| **Edge Device 블로킹** | 3,000ms+ | **0ms** (비동기) | **완전 해소** | Before: 구조 추정 / After: MQTT QoS 1 PUBACK | -| **메시지 보장** | 없음 | **QoS 1 (At-Least-Once)** | **메시지 무손실** | 프로토콜 사양 | -| **장애 격리** | 전체 영향 | **컴포넌트별 격리** | **독립 운영** | 아키텍처 설계 | -| **확장성** | 서버 전체 | **Worker별 독립** | **세밀한 확장** | 아키텍처 설계 | -| **HTTP API p95** | N/A | **38.85ms** | - | 실측 (k6 4시나리오, n=2,297) | -| **스파이크 대응** | 서버 다운 위험 | **15 VUs에서 안정 (에러율 0%)** | **고가용성** | 실측 (k6 spike 시나리오) | +#### 대시보드 폴링 비교 (시나리오 A) + +| 메트릭 | v1 (동기 OCR) | v2 (비동기 EDA) | 개선율 | 비고 | +|-------|-------------|---------------|--------|------| +| dashboard p95 | 2.66s | - | - | 3 VUs, 동일 조건 | +| 목록 조회 p95 | 215.87ms | - | - | v1:cars / v2:detections | +| 미처리 목록 p95 | 149.37ms | - | - | v1:unchecked / v2:pending | +| 에러율 | 0% | - | - | | + +#### 혼합 워크로드 비교 (시나리오 C) + +| 메트릭 | v1 (동기 OCR) | v2 (비동기 EDA) | 개선율 | 비고 | +|-------|-------------|---------------|--------|------| +| 읽기 p95 | 351.21ms (전체 http p95) | - | - | 0→9 VUs, 별도 커스텀 메트릭 없음 | +| 쓰기 p95 | 24.2s (OCR POST p95) | - | - | v1:OCR POST / v2:차량등록 | +| 에러율 | 0.55% (전체) | - | - | | -> **비교 기준 참고:** Before 수치는 동기 OCR 처리 구조(HTTP 요청 → OCR 완료 후 응답)에서의 설계 기반 추정값이며, After 수치는 현재 운영 환경에서의 실측값입니다. +#### 스파이크 내성 비교 (시나리오 D) -### 5.2 아키텍처 전환 핵심 성과 +| 메트릭 | v1 (동기 OCR) | v2 (비동기 EDA) | 개선율 | 비고 | +|-------|-------------|---------------|--------|------| +| p95 응답시간 | 128.06ms | - | - | 0→15 VUs, 읽기 전용 | +| 에러율 | 0% | - | - | | +| 최대 RPS | - | - | - | 테스트 후 기록 | + +#### 스트레스 테스트 비교 (시나리오 E, F) + +| 메트릭 | v1 (동기 OCR) | v2 (비동기 EDA) | 개선율 | 비고 | +|-------|-------------|---------------|--------|------| +| stress_ramp 읽기 p95 | 337.41ms | - | - | 0→50 VUs, 읽기 전용 | +| stress_ramp 에러율 | 0% | - | - | | +| stress_mixed 읽기 p95 | - (전체 p95 351.21ms) | - | - | 0→50 VUs, 80% 읽기 | +| stress_mixed 쓰기 p95 | 30s | - | - | **v1: OCR POST / v2: 차량등록** | +| stress_mixed 에러율 | 19.51% | - | - | | +| 안정 최대 TPS | - | - | - | 테스트 후 기록 | + +> **핵심 비교 포인트:** stress_mixed 시나리오에서 v1의 20% OCR 쓰기가 전체 시스템 응답시간에 미치는 영향 vs v2에서 OCR이 분리되어 쓰기(차량등록)가 시스템에 미치는 영향이 최소화되는 차이를 확인하세요. + +> **비교 기준 참고:** v1 수치는 `depoly-v1/k6/load-test-v1.js` 실행 결과, v2 수치는 `backend/docker/k6/load-test.js` 실행 결과입니다. + +### 5.2 OCR 처리 방식 비교 + +| 비교 항목 | v1 (동기 HTTP) | v2 (비동기 MQTT+AMQP) | +|----------|---------------|---------------------| +| OCR 실행 위치 | Django HTTP 스레드 내 | 전용 ocr-worker (별도 인스턴스) | +| HTTP 스레드 점유 | OCR 완료까지 점유 (3~10초) | OCR과 무관 (즉시 응답) | +| Edge Device 블로킹 | 응답 대기 3초+ | MQTT PUBACK 즉시 (<1ms) | +| OCR 부하 시 API 영향 | 전체 API 응답시간 증가 | API 영향 없음 | +| 동시 OCR 처리 | Gunicorn 스레드 수에 종속 | Worker concurrency로 독립 제어 | +| OCR 장애 시 | API 전체 장애 | API 정상, OCR 큐에 보존 | +| 확장 방법 | Django 서버 전체 스케일 아웃 | OCR Worker만 독립 스케일 아웃 | + +> **stress_mixed에서의 차이:** +> - v1: 20% OCR POST → HTTP 스레드 3~10초 점유 → 나머지 80% 읽기 요청도 큐잉 → **시스템 전체 응답시간 급등** +> - v2: 20% 차량등록 POST → <300ms 처리 → 읽기 요청에 영향 미미 → **시스템 안정** + +### 5.2.1 인프라 비용 대비 성능 비교 + +| 항목 | v1 (모놀리식) | v2 (분산 EDA) | 비고 | +|------|-------------|-------------|------| +| 인스턴스 수 | 1 (e2-standard-2) | 6 (e2-small) | | +| 총 vCPU | 2 | 12 | 6배 | +| 총 RAM | 4 GB | 12 GB (+ e2-medium 4GB DB) | 4배 | +| HTTP API TPS | - | - | [테스트 후 기록] | +| 이벤트 처리량 | 동기 OCR 제약 | - | [테스트 후 기록] | +| 장애 격리 | 불가 (모놀리식) | 컴포넌트별 격리 | 구조적 개선 | +| 독립 확장 | 불가 | Worker별 확장 | 구조적 개선 | + +### 5.3 아키텍처 전환 핵심 성과 ```mermaid graph LR @@ -710,7 +955,7 @@ graph LR subgraph After["Event Driven Architecture"] A1["Django
(API만)"] - A2["15ms 처리"] + A2["[실측값]ms 처리"] A3["MQTT+AMQP"] A4["장애 격리"] style A1 fill:#90EE90 @@ -726,7 +971,7 @@ graph LR | 기존 문제 | 해결 방법 | 효과 | |----------|----------|------| -| **OCR 동기 처리** | OCR Worker 분리 + AMQP 비동기 처리 | 이벤트 처리시간 3000ms → 15ms | +| **OCR 동기 처리** | OCR Worker 분리 + AMQP 비동기 처리 | 이벤트 처리시간 대폭 단축 (재측정 예정) | | **Edge Device 블로킹** | MQTT QoS 1 + 즉시 ACK | 연속 감지 가능, 데이터 유실 방지 | | **HTTP IoT 통신** | MQTT 프로토콜 도입 | 경량 프로토콜, 메시지 보장, 오프라인 버퍼링 | | **장애 전파** | 컴포넌트 분리 + 이벤트 큐 보존 | OCR 장애 시에도 API 정상 운영 | @@ -749,9 +994,9 @@ graph LR | **Celery Workers** | Task 처리량, 지연 시간, 실패율 | | **Application Logs** | Loki 기반 통합 로그 검색 | -**📸 [스크린샷 삽입: System Overview 대시보드 - 6개 인스턴스 CPU/Memory 전체 현황]** +**[스크린샷: System Overview 대시보드 - 6개 인스턴스 CPU/Memory 전체 현황]** -**📸 [스크린샷 삽입: MySQL Performance 대시보드 - 커넥션 수 변화 (부하 테스트 구간)]** +**[스크린샷: MySQL Performance 대시보드 - 커넥션 수 변화 (부하 테스트 구간)]** ### 6.2 Prometheus 타겟 상태 @@ -771,7 +1016,7 @@ graph LR | celery | speedcam-ocr | ✅ UP | | otel | speedcam-mon | ✅ UP | -**📸 [스크린샷 삽입: Prometheus → Status → Targets 페이지 (11개 타겟 All UP)]** +**[스크린샷: Prometheus → Status → Targets 페이지 (11개 타겟 All UP)]** ### 6.3 로그 수집 현황 @@ -792,17 +1037,17 @@ graph LR | 컴포넌트 | 이론값 | 실측값 | 근거 | 병목 요인 | |---------|-------|-------|------|----------| -| **HTTP API (Django)** | ~80-100 req/s | **25 req/s (50VUs)** | k6 스트레스 테스트 실측 | Gunicorn 4 handlers + k6 리소스 경합 | -| **HTTP API (15VUs)** | - | **6.75 req/s (p95=39ms)** | k6 4시나리오 실측 (실제 사용 패턴) | sleep 간격으로 낮은 req/s, 응답은 빠름 | -| **MQTT Subscriber** | ~40 msg/s | **20 msg/s 무손실** | Burst 시나리오 (1200건/60초) | 단일 스레드 loop_forever() | -| **MQTT Publish** | - | **0.37~0.91ms/건** | 3개 시나리오 실측 | 지연 무시 가능 | +| **HTTP API (Django)** | ~80-100 req/s | - | 재측정 예정 | Gunicorn 4 handlers + k6 리소스 경합 | +| **HTTP API (15VUs)** | - | - | 재측정 예정 | sleep 간격으로 낮은 req/s, 응답은 빠름 | +| **MQTT Subscriber** | ~40 msg/s | - | 재측정 예정 | 단일 스레드 loop_forever() | +| **MQTT Publish** | - | - | 재측정 예정 | 지연 무시 가능 | | **AMQP Broker** | ~10,000 msg/s | - | RabbitMQ 공식 벤치마크 참고 | 충분한 여유 (병목 없음) | -| **OCR Worker (단건)** | ~0.2 msg/s | **0.2 msg/s** | 단건 실측 (5.1s/건, n=3) | EasyOCR CPU 추론 | -| **OCR Worker (부하 시)** | - | **0.053~0.073 msg/s** | 3개 시나리오 실측 (13.7~18.8s/건) | 메모리 압박 + GCS 경합 | -| **Alert Worker** | ~100 msg/s | - | 추정 (concurrency=100 설정) | FCM API 호출 | +| **OCR Worker (단건)** | ~0.2 msg/s | - | 재측정 예정 | EasyOCR CPU 추론 | +| **OCR Worker (부하 시)** | - | - | 재측정 예정 | 메모리 압박 + GCS 경합 | +| **Alert Worker** | ~100 msg/s | - | 추정 (Celery gevent concurrency=100 설정) | FCM API 호출 | | **MySQL** | ~500 qps | - | 추정 (e2-medium 벤치마크) | e2-medium 4GB RAM | -> **참고:** HTTP 실측값은 k6가 동일 인스턴스(e2-small)에서 실행된 결과. MQTT Subscriber는 Burst(20 msg/s)에서도 1,200건 전량 수신하여 단일 스레드임에도 충분한 처리량 확인. OCR Worker가 전체 파이프라인의 지배적 병목. +> **참고:** HTTP 실측값은 k6가 동일 인스턴스(e2-small)에서 실행된 결과로 별도 클라이언트 사용 시 더 높을 수 있음. OCR Worker가 전체 파이프라인의 지배적 병목으로 예상. ### 7.2 파이프라인 전체 병목 @@ -810,27 +1055,25 @@ graph LR ```mermaid graph LR - A["HTTP API
25 req/s (실측)"] ~~~ B - B["MQTT Subscriber
20 msg/s 처리 확인"] -->|"병목"| C["OCR Worker
0.06 msg/s (부하시 실측)"] + A["HTTP API
(재측정 예정)"] ~~~ B + B["MQTT Subscriber
(재측정 예정)"] -->|"병목"| C["OCR Worker
(재측정 예정)"] C --> D["Alert Worker
~100 msg/s (추정)"] style C fill:#ff6666 ``` -**실측 데이터 기반 병목 분석 (3 시나리오 종합):** -- **OCR Worker가 전체 파이프라인의 지배적 병목**임이 3개 시나리오에서 일관되게 확인됨 -- 단건 처리: 5.1s/건 (0.2 msg/s) → **동시 부하 시: 13.7~18.8s/건 (0.053~0.073 msg/s)로 2.7~3.7배 성능 저하** -- Normal(0.33 msg/s)에서도 큐 피크 25, 300초 내 80%만 완료 -- Rush Hour(1.67 msg/s)에서 큐 피크 202, 300초 내 11%만 완료 -- Burst(20 msg/s)에서 큐 피크 1,381, 300초 내 1.5%만 완료 → 드레인 약 6.3시간 소요 +**병목 분석 (구조 기반):** +- **OCR Worker가 전체 파이프라인의 지배적 병목**으로 예상 - e2-small(2GB)에서 EasyOCR concurrency=1만 가능 (메모리 제약) +- 동시 부하 시 메모리 압박 + GCS 경합으로 성능 저하 발생 예상 +- 구체적 수치는 테스트 수행 후 기록 **해결 방안:** | 방법 | 예상 개선 | 비용 | 난이도 | |------|----------|------|-------| -| OCR 인스턴스 추가 (horizontal) | 0.053 msg/s × N | 저 | 낮음 | -| GPU 인스턴스 전환 | 5.1s → <1s (5x+) | 중 | 중 | +| OCR 인스턴스 추가 (horizontal) | 처리량 N배 향상 | 저 | 낮음 | +| GPU 인스턴스 전환 | OCR 추론 시간 대폭 단축 (CPU 대비 5x+ 목표) | 중 | 중 | | e2-medium 업그레이드 | 메모리 여유로 부하 시 성능 저하 완화 | 저 | 낮음 | | 경량 OCR 모델 (PaddleOCR) | ~2-3x 빠름 | 저 | 중 | | Edge 전처리 | 이미지 크기 감소 | 저 | 낮음 | @@ -854,7 +1097,7 @@ graph LR | 이슈 | 현재 상태 | 영향도 | 개선 방안 | |------|----------|--------|----------| | **FCM 토큰 업데이트 API** | PATCH 엔드포인트 0% 성공률 | 🔴 High | API endpoint 로직 수정 | -| **OCR Worker 확장성** | 단일 워커 0.2 msg/s | 🔴 High | GPU 인스턴스 또는 경량 OCR 모델 검토 | +| **OCR Worker 확장성** | 단일 워커, 메모리 제약으로 concurrency=1 | 🔴 High | GPU 인스턴스 또는 경량 OCR 모델 검토 | | **모니터링 인스턴스 메모리** | 264MB 여유 (메모리 부족 위험) | 🟡 Medium | e2-medium 업그레이드 권장 | #### 8.2.2 최적화 (Medium Priority) @@ -883,16 +1126,15 @@ SpeedCam 시스템은 기존 동기식 HTTP 기반 모놀리식 아키텍처에 **정량적 성과:** -| 지표 | Before | After | 개선 | 근거 | +| 지표 | v1 (동기 OCR) | v2 (비동기 EDA) | 개선 | 근거 | |------|--------|-------|------|------| -| 이벤트 처리 시간 | 3,000ms+ ¹ | **15ms** | **200배** | After 실측 (n=4) | -| Edge Device 블로킹 | 3,000ms+ ¹ | **0ms** | **완전 해소** | MQTT PUBACK | -| HTTP API p95 | N/A | **38.85ms** | - | k6 4시나리오 실측 (n=2,297) | -| MQTT 발행 성공률 | N/A | **100%** (20 msg/s까지) | - | MQTT 3시나리오 실측 (n=1,440) | -| 메시지 보장 | 없음 | **QoS 1** | **무손실** | 프로토콜 사양 + DLQ 0건 실측 | -| 스파이크 에러율 | 서버 다운 위험 ¹ | **0%** | **고가용성** | k6 실측 (15 VUs) | - -> ¹ Before 수치는 동기 OCR 처리 구조 기반 설계 추정값 (별도 부하 테스트 미수행) +| 이벤트 처리 시간 | OCR avg 15.1s (동기) | 재측정 예정 | - | v1: 실측 / v2: 재측정 예정 | +| Edge Device 블로킹 | OCR 완료까지 대기 (avg 15.1s) | **0ms** | **완전 해소** | v1: 실측 / v2: MQTT PUBACK | +| HTTP API p95 (스파이크) | 128.06ms (읽기 전용) | 재측정 예정 | - | v1: spike_resilience 실측 | +| OCR 동시 처리 성공률 | 28% (stress_mixed, 50 VUs) | 재측정 예정 | - | v1: 실측 / v2: 재측정 예정 | +| 메시지 보장 | 없음 | **QoS 1** | **무손실** | 프로토콜 사양 | +| 스파이크 에러율 | 0% (읽기 전용) / 19.51% (OCR 혼합) | 재측정 예정 | - | v1: 실측 | +| stress_mixed OCR 에러율 | 19.51% | 재측정 예정 | - | v1: 실측 (28% OCR POST 성공) | **정성적 성과:** @@ -900,6 +1142,7 @@ SpeedCam 시스템은 기존 동기식 HTTP 기반 모놀리식 아키텍처에 2. **독립 확장**: Worker별 독립적 스케일 아웃 3. **완전한 관측성**: Prometheus + Grafana + Loki + Jaeger 통합 모니터링 4. **IoT 최적화**: MQTT QoS 1로 메시지 전달 보장 +5. **Choreography 패턴**: OCR Worker → Alert Worker 직접 domain event 전달 (Main Service 불개입) ### 9.2 개선 로드맵 @@ -923,14 +1166,14 @@ SpeedCam 시스템은 기존 동기식 HTTP 기반 모놀리식 아키텍처에 SpeedCam 프로젝트는 **Event Driven Architecture**를 통해 기존 모놀리식 구조의 근본적 한계를 극복하고, 실시간 IoT 시스템으로서 요구되는 **높은 응답성**, **메시지 보장**, **장애 격리**를 모두 달성했습니다. -특히 **이벤트 처리 시간 200배 개선 (3,000ms+ → 15ms)**, **완전한 비동기 처리**, **컴포넌트별 독립 확장**이라는 핵심 목표를 성공적으로 구현하여, 프로덕션 환경에서 안정적으로 운영 가능한 시스템으로 발전했습니다. +특히 **완전한 비동기 처리**, **Choreography 패턴 기반 도메인 이벤트 흐름 (OCR Worker → Alert Worker Kombu Consumer)**, **컴포넌트별 독립 확장**이라는 핵심 목표를 성공적으로 구현하여, 프로덕션 환경에서 안정적으로 운영 가능한 시스템으로 발전했습니다. -앞으로 OCR Worker GPU 전환과 DB 커넥션 풀링 최적화를 통해 더욱 빠르고 효율적인 시스템으로 발전할 것으로 기대됩니다. +앞으로 OCR Worker GPU 전환과 DB 커넥션 풀링 최적화를 통해 더욱 빠르고 효율적인 시스템으로 발전할 것으로 기대됩니다. 정량적 성과 수치는 재측정 후 기록됩니다. --- -**문서 버전:** 2.0 -**최종 수정일:** 2026-02-12 +**문서 버전:** 3.0 +**최종 수정일:** 2026-02-19 **테스트 일시:** 2026-02-12 (k6 HTTP 4시나리오 + MQTT 3시나리오) **작성자:** SpeedCam Backend Team **관련 문서:** [ARCHITECTURE_COMPARISON.md](./ARCHITECTURE_COMPARISON.md)