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)