SpeedCam은 도로 위 과속 차량을 실시간으로 감지하고 번호판을 인식하여 사용자에게 알림을 전송하는 이벤트 기반 실시간 시스템입니다.
- Event Driven Architecture: MQTT + AMQP 기반 비동기 메시지 처리
- 분산 시스템: GCE 인스턴스 6대로 구성된 마이크로서비스 아키텍처
- 실시간 OCR 처리: EasyOCR을 활용한 한국어 번호판 인식
- 완전한 관측성: Prometheus, Grafana, Loki, Jaeger를 통한 통합 모니터링
- Backend: Django 4.2 + Gunicorn
- Message Broker: RabbitMQ 3.13 (MQTT Plugin + AMQP)
- Database: MySQL 8.0
- OCR Engine: EasyOCR (Korean + English)
- Monitoring: Prometheus, Grafana, Loki, Jaeger, OpenTelemetry
- Infra: GCP Compute Engine (6 instances), Docker Compose
- Load Testing: k6 (Grafana k6), Python paho-mqtt
기존 시스템은 Django 모놀리식 구조로, OCR 처리가 동기적으로 수행되어 다음과 같은 구조적 한계가 있었습니다.
graph TB
subgraph Edge["Edge Device (Raspberry Pi)"]
Camera["과속 카메라"]
end
subgraph Backend["backend (Django)"]
API["API Handler"]
OCR["OCR 처리<br/>(동기 실행)"]
end
subgraph Workers["Celery Workers"]
CW["celery_worker<br/>(알림 전송)"]
DLQ["celery_worker_dlq"]
end
Camera -->|"HTTP POST"| API
API --> OCR
Backend --> RMQ["RabbitMQ"]
CW --> RMQ
Backend --> MySQL[("MySQL")]
CW --> MySQL
style Backend fill:#ffcccc,stroke:#cc0000
style OCR fill:#ff9999
| 문제 영역 | 상세 내용 |
|---|---|
| OCR 동기 처리 | OCR 작업(약 3초)이 HTTP 스레드를 점유하여 서버 처리량 저하 |
| Edge Device 블로킹 | 서버 응답 대기(3초+)로 인한 연속 감지 불가, 데이터 유실 위험 |
| HTTP 기반 IoT 통신 | 요청마다 TCP 연결, 메시지 보장 없음, 오프라인 처리 불가 |
| 장애 전파 | OCR 장애 시 API 서비스 전체 영향, 독립 확장 불가 |
성능 지표 (Before) — 아키텍처 구조 기반 추정값
기존 아키텍처는 현재 운영 환경에서 별도로 부하 테스트를 수행하지 않았습니다. 아래 수치는 동기 OCR 처리 시간(EasyOCR CPU 기준 ~3초)과 아키텍처 구조로부터 도출한 설계 기반 추정값입니다.
- 이벤트 처리 시간: 3,000ms 이상 (HTTP 수신 → OCR 완료까지 동기 처리)
- Edge Device 블로킹: 3,000ms 이상 (HTTP 응답 대기)
- 메시지 보장: 없음
- 장애 격리: 불가능 (모놀리식 구조)
기존 문제를 해결하기 위해 Event Driven Architecture로 전환하여 MQTT 기반 IoT 통신과 AMQP 기반 비동기 메시지 처리를 구현했습니다.
graph TB
subgraph Edge["Edge Device"]
Camera["과속 카메라"]
end
subgraph Main["main (Django)"]
API["API Handler"]
MQTT_Sub["MQTT Subscriber"]
Publisher["Event Publisher"]
end
subgraph Workers["Event Processors"]
OCR["ocr-worker<br/>• 감지 이벤트 처리<br/>• OCR 수행<br/>• detections.completed 발행"]
subgraph AlertWorker["alert-worker"]
KombuConsumer["Kombu Consumer<br/>(단일 스레드)<br/>domain event 구독"]
CeleryGevent["Celery gevent pool<br/>(concurrency=100)<br/>FCM 병렬 전송"]
end
end
subgraph MessageBroker["RabbitMQ"]
MQTT["MQTT Plugin"]
Queue1[("감지 이벤트 큐")]
DomainEvents[("domain_events exchange<br/>detections.completed")]
end
subgraph Storage["Google Cloud Storage"]
GCS[("GCS Bucket<br/>번호판 이미지")]
end
Camera -->|"MQTT Publish"| MQTT
Camera -->|"이미지 업로드"| GCS
MQTT --> MQTT_Sub
Publisher --> Queue1
Queue1 --> OCR
OCR -->|"이미지 다운로드"| GCS
OCR -->|"domain event 발행"| DomainEvents
DomainEvents -->|"choreography"| KombuConsumer
KombuConsumer -->|"send_notification.delay()"| CeleryGevent
Main --> DB1[("default")]
Main --> DB2[("vehicles_db")]
OCR --> DB3[("detections_db")]
AlertWorker --> DB4[("notifications_db")]
style Main fill:#90EE90
style OCR fill:#87CEEB
style AlertWorker fill:#DDA0DD
style KombuConsumer fill:#C8A2C8
style CeleryGevent fill:#DDA0DD
style MessageBroker fill:#FFB6C1
style Storage fill:#FFFACD
| 컴포넌트 | 역할 | 프로토콜 | 특징 |
|---|---|---|---|
| Edge Device | 과속 차량 감지 | MQTT | QoS 1, 경량, 영구 연결 |
| main (Django) | API + MQTT 구독 | HTTP + MQTT | 이벤트 발행만 담당 |
| ocr-worker | 번호판 OCR 처리 | AMQP | 비동기 처리, concurrency=1 |
| 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 보장 |
sequenceDiagram
participant Edge as Edge Device
participant RMQ as RabbitMQ
participant Main as main
participant OCR as ocr-worker
participant Kombu as alert-worker<br/>(Kombu Consumer)
participant Celery as alert-worker<br/>(Celery gevent)
participant User as 사용자 앱
Edge->>RMQ: MQTT Publish (과속 차량 감지)
RMQ-->>Edge: PUBACK (즉시)
RMQ->>Main: 메시지 전달 (subscribe)
Main->>Main: DB 저장 (pending)
Main->>RMQ: 감지 이벤트 발행 (AMQP)
RMQ->>OCR: 감지 이벤트 수신
OCR->>OCR: 번호판 OCR 처리
OCR->>OCR: DB 업데이트 (completed)
OCR->>RMQ: detections.completed 발행 (domain_events exchange)
Note over RMQ,Kombu: Choreography 패턴 — Main Service 불개입
RMQ->>Kombu: domain event 수신 (직접 구독)
Kombu->>Celery: send_notification.delay()
Celery->>User: FCM Push 알림 (gevent 병렬)
총 6대의 GCE 인스턴스로 구성된 분산 시스템입니다. 모든 인스턴스는 asia-northeast3-a 존에 위치하며 Ubuntu 22.04 LTS, Kernel 6.8.0-1045-gcp, Docker 기반으로 운영됩니다.
| 인스턴스 | 머신 타입 | vCPU | RAM | 디스크 | 디스크 사용률 | 내부 IP | 역할 |
|---|---|---|---|---|---|---|---|
| speedcam-app | e2-small | 2 | 2GB | 20GB | 34% (6.4GB) | 10.178.0.4 | API 서버 |
| speedcam-db | e2-medium | 2 | 4GB | 29GB | 20% (5.8GB) | 10.178.0.2 | 데이터베이스 |
| speedcam-mq | e2-small | 2 | 2GB | 20GB | 26% (4.9GB) | 10.178.0.7 | 메시지 브로커 |
| speedcam-ocr | e2-small | 2 | 2GB | 20GB | 87% (17GB) | 10.178.0.3 | OCR Worker |
| speedcam-alert | e2-small | 2 | 2GB | 20GB | 31% (5.9GB) | 10.178.0.6 | Alert Worker |
| speedcam-mon | e2-small | 2 | 2GB | 20GB | 37% (7.0GB) | 10.178.0.5 | 모니터링 |
| 인스턴스 | 컨테이너 | 역할 |
|---|---|---|
| speedcam-app | Django + Gunicorn | REST API (GUNICORN_WORKERS=2) |
| Traefik | 리버스 프록시 | |
| Flower | Celery 모니터링 | |
| Promtail | 로그 수집 에이전트 | |
| cAdvisor | 컨테이너 메트릭 수집 | |
| speedcam-db | MySQL 8.0 | 메인 데이터베이스 |
| mysqld-exporter | MySQL 메트릭 수집 | |
| Promtail | 로그 수집 에이전트 | |
| cAdvisor | 컨테이너 메트릭 수집 | |
| speedcam-mq | RabbitMQ 3.13 | MQTT + AMQP 브로커 |
| Promtail | 로그 수집 에이전트 | |
| cAdvisor | 컨테이너 메트릭 수집 | |
| speedcam-ocr | Celery OCR Worker | EasyOCR 처리 (concurrency=1) |
| Promtail | 로그 수집 에이전트 | |
| cAdvisor | 컨테이너 메트릭 수집 | |
| speedcam-alert | Kombu Consumer + Celery gevent Worker | FCM 알림 발송 (gevent concurrency=100) |
| Promtail | 로그 수집 에이전트 | |
| cAdvisor | 컨테이너 메트릭 수집 | |
| speedcam-mon | Prometheus | 메트릭 수집 |
| Grafana | 시각화 대시보드 | |
| Loki | 로그 수집 | |
| Jaeger | 분산 추적 | |
| OpenTelemetry Collector | 텔레메트리 수집 | |
| Promtail | 로그 수집 에이전트 | |
| cAdvisor | 컨테이너 메트릭 수집 |
재측정 예정 — 아래 수치는 참고용이며 현재 상태와 다를 수 있습니다.
| 인스턴스 | 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 | Kombu Consumer + Celery gevent | 충분한 여유 |
| speedcam-mon | 1.5GB/2GB | 264MB | Prometheus + Grafana | 메모리 부족 위험 |
주의사항:
speedcam-ocr: EasyOCR 모델 로딩으로 인한 높은 메모리 사용률, concurrency를 1로 제한speedcam-mon: 모니터링 스택의 메모리 집약적 특성으로 264MB 여유분만 확보
실제 운영 환경에서의 시스템 성능과 안정성을 검증하기 위해 다음 목표로 부하 테스트를 수행했습니다.
| 목표 | 세부 내용 |
|---|---|
| 성능 한계 파악 | 각 컴포넌트별 최대 처리량 측정 |
| 병목 지점 식별 | Event Driven 파이프라인 각 단계별 소요 시간 분석 |
| 아키텍처 검증 | 기존 동기 처리 대비 비동기 이벤트 기반 처리의 성능 개선 정도 확인 |
| 안정성 확인 | 스파이크 트래픽 발생 시 시스템의 안정성 검증 |
| 도구 | 용도 | 특징 |
|---|---|---|
| k6 (Grafana k6) | HTTP API 부하 테스트 | Prometheus Remote Write로 메트릭 실시간 전송, 웹 대시보드 + Grafana 연동 |
| Python + paho-mqtt | MQTT 파이프라인 부하 테스트 | 실제 한국어 번호판 이미지를 GCS에 저장하여 실 파이프라인 테스트, EasyOCR 실제 동작 검증 |
| 항목 | 상세 |
|---|---|
| 테스트 일시 (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) |
| 시스템 상태 | 테스트 외 트래픽 없음 (전용 테스트 환경) |
참고: k6 HTTP 테스트는 speedcam-app 자체에서 localhost로 호출하였으므로, 측정된 응답 시간은 순수 서버 처리 시간에 가깝습니다. 실제 클라이언트에서의 응답 시간은 네트워크 지연이 추가됩니다.
Django REST API의 처리 성능과 응답 시간을 측정하기 위해 4가지 시나리오로 부하 테스트를 수행했습니다.
| 시나리오 | VUs | Executor | 지속시간 | 시작 시점 | 설명 |
|---|---|---|---|---|---|
| dashboard_polling | 3 (constant) | constant-vus | 2분 | 0s | 대시보드 폴링 (감지목록 5초, 알림 10초, 통계 30초 주기) |
| admin_ops | 2 | constant-arrival-rate (2/min) | 2분 | 0s | 관리자 작업 (차량 등록 + FCM 토큰 업데이트) |
| mixed_workload | 0→5→9→9→0 | ramping-vus | 2분30초 | 2m | 읽기 60% + 파이프라인 상태 30% + 쓰기 10% |
| spike_resilience | 0→3→15→15→3→0 | ramping-vus | 1분10초 | 4m30s | 급격한 트래픽 증가 시 회복력 (15 VUs = 4 핸들러 대비 3.75배) |
총 테스트 시간: 5분 40초, 최대 동시 VUs: 18
테스트 후 기록
응답 시간 분포
| 메트릭 | avg | min | med | max | p(90) | p(95) |
|---|---|---|---|---|---|---|
| dashboard_req_duration | - | - | - | - | - | - |
| admin_req_duration | - | - | - | - | - | - |
| detections_list_duration | - | - | - | - | - | - |
| statistics_req_duration | - | - | - | - | - | - |
| pending_read_duration | - | - | - | - | - | - |
| spike_resilience (overall) | - | - | - | - | - | - |
| http_req_duration (전체) | - | - | - | - | - | - |
[스크린샷: k6 Grafana 대시보드 - 4 시나리오 응답시간 그래프]
임계치(Threshold) 검증 결과:
| 임계치 | 기준 | 실측 | 판정 |
|---|---|---|---|
| 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% | - | - |
주요 인사이트:
테스트 후 기록
| Check 항목 | 성공/전체 | 성공률 | 비고 |
|---|---|---|---|
| 서버 헬스체크 | - | - | - |
| 차량 등록 (201) | - | - | admin_ops + mixed 시나리오 |
| FCM 토큰 업데이트 (200) | - | - | PATCH 엔드포인트 |
| 감지 목록 (200) | - | - | dashboard + spike 시나리오 |
| 알림 목록 (200) | - | - | dashboard 시나리오 |
| 통계 조회 (200) | - | - | dashboard + spike 시나리오 |
| 대기 목록 (200) | - | - | mixed 시나리오 |
| 혼합 읽기 (200) | - | - | mixed 시나리오 |
| 혼합 차량 등록 (201) | - | - | mixed 시나리오 |
| 스파이크 감지 목록 | - | - | - |
| 스파이크 알림 목록 | - | - | - |
| 스파이크 통계 | - | - | - |
테스트 후 기록
| 항목 | 값 | 근거 |
|---|---|---|
| 현재 설정 | GUNICORN_WORKERS=2 (각 2 threads = 총 4 HTTP handlers) | 배포 환경 (env.example 기본값=4와 다름) |
| 4시나리오 테스트 | - | k6 4시나리오 실측 후 기록 |
| 스트레스 테스트 | - | k6 stress_ramp 실측 후 기록 |
| 포화점 | - | 재측정 예정 |
| 안정 최대 TPS | - | 재측정 예정 |
| 이론 최대 TPS | ~80-100 req/s | 4 handlers × 평균 20ms 기준 |
| 주요 병목 | Gunicorn 핸들러 포화 + DB 커넥션 (CONN_MAX_AGE 미설정) | 스트레스 테스트 분석 |
측정 근거: 테스트 수행 후 기록 예정
확장 방법:
CONN_MAX_AGE설정으로 커넥션 풀링 활성화GUNICORN_WORKERS증가 (CPU 코어당 1-2개 권장)- 인스턴스 업그레이드 (e2-medium 이상)
기존 테스트(최대 15 VUs)에서는 시스템이 여유 있게 처리하여 실제 한계점을 파악하지 못했습니다. 이를 보완하기 위해 VUs를 점진적으로 50까지 올리는 스트레스 테스트를 수행했습니다.
주의: k6가 speedcam-app 동일 인스턴스(e2-small, 2 vCPU)에서 실행되므로, k6 자체의 CPU/메모리 사용이 결과에 영향을 줄 수 있습니다.
테스트 구성
| Phase | 시나리오 | VUs | 지속시간 | 요청 유형 |
|---|---|---|---|---|
| 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 메트릭 기록됨)
테스트 후 기록
응답 시간 분포
| 메트릭 | avg | med | p(90) | p(95) | max |
|---|---|---|---|---|---|
| 전체 (req_duration) | - | - | - | - | - |
| 읽기 (read_latency) | - | - | - | - | - |
| 쓰기 (write_latency) | - | - | - | - | - |
Phase별 에러율
| Phase | Check | 성공률 | 실패율 |
|---|---|---|---|
| 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의 요청 생성 속도가 제한됩니다. 이로 인해 서버에 실제 도달하는 부하가 줄어 에러율은 낮아지나, 시스템 전체 리소스 경합으로 응답 시간(p95)은 증가합니다.
[스크린샷: k6 Grafana 대시보드 - VUs 변화에 따른 응답시간/에러율 그래프]
[스크린샷: Container Metrics - speedcam-app의 CPU/Memory 그래프 (스트레스 테스트 구간)]
부하 수준별 성능 비교 (실측)
| VUs | 시나리오 | p95 | 에러율 | 처리량 | 판정 |
|---|---|---|---|---|---|
| 15 | spike_resilience | - | - | - | - |
| 50 | stress_ramp | - | - | - | - |
| 50 | stress_mixed | - | - | - | - |
핵심 발견:
테스트 후 기록
실제 Edge Device에서 발생하는 과속 감지 이벤트부터 OCR 처리, 알림 발송까지 End-to-End 파이프라인 성능을 측정했습니다.
| 항목 | 상세 |
|---|---|
| 테스트 방식 | 단건 순차 발행 (동시 부하 아님) |
| 테스트 샘플 수 | 5건 (통계적 유의성보다는 파이프라인 각 단계별 동작 검증 목적) |
| MQTT 발행 위치 | speedcam-app (10.178.0.4) → speedcam-mq (10.178.0.7), 동일 VPC |
| 테스트 이미지 | 한국어 번호판 합성 이미지 10장 (PIL로 생성) |
| 이미지 특징 | 고대비 흰 배경 + 검정 텍스트 (OCR 최적화) |
| GCS 버킷 | gs://speedcam-bucket-4f918446/detections/ |
| OCR Worker | EasyOCR (Korean + English), concurrency=1, Warm 상태 (모델 사전 로딩) |
| 인증 방식 | GCE ADC (메타데이터 서버, JSON 키 없음) |
| 측정 방법 | 각 컨테이너 로그의 타임스탬프 비교 (Loki 수집) |
참고: 본 테스트는 동시 다발적인 부하 상황이 아닌, 파이프라인 각 단계의 단위 처리 시간 측정에 초점을 맞추었습니다. 대량 동시 처리 시의 성능은 큐 깊이 증가와 OCR Worker 대기 시간 등의 추가 요소가 발생합니다.
전체 파이프라인은 다음과 같이 3단계로 구성됩니다:
Stage 1: MQTT 수신 → Detection 생성 → OCR Task 디스패치
Stage 2: AMQP 전달 (Subscriber → OCR Worker)
Stage 3: OCR 처리 (GCS 다운로드 + EasyOCR 추론)
Stage 1: MQTT 수신 → Detection 생성 → OCR Task 디스패치 (Subscriber)
| Detection ID | MQTT 수신 시각 | Detection 생성 | OCR 디스패치 | 총 Subscriber 처리 시간 |
|---|---|---|---|---|
| - | - | - | - | - |
| - | - | - | - | - |
| - | - | - | - | - |
| - | - | - | - | - |
| - | - | - | - | - |
평균 Subscriber 처리 시간: 테스트 후 기록
- JSON 파싱 + DB Insert + AMQP Publish 포함
- Cold Start 시 DB 커넥션 수립 시간 포함으로 이상값 발생 가능 (이후 안정화)
Stage 2: AMQP 전달 (Subscriber → OCR Worker)
| Detection ID | 디스패치 시각 | Worker 수신 시각 | AMQP 전달 시간 |
|---|---|---|---|
| - | - | - | - |
| - | - | - | - |
| - | - | - | - |
평균 AMQP 전달 시간: 테스트 후 기록
- RabbitMQ 내부 라우팅 오버헤드 매우 낮음
Stage 3: OCR 처리 (GCS 다운로드 + EasyOCR 추론)
| Detection ID | 이미지 | OCR 처리 시간 | 인식 결과 | 신뢰도 | 비고 |
|---|---|---|---|---|---|
| - | - | - | - | - | - |
| - | - | - | - | - | - |
| - | - | - | - | - | - |
| - | - | - | - | - | - |
| - | - | - | - | - | - |
OCR 성능 요약:
| 지표 | 값 |
|---|---|
| Cold Start (모델 로딩 포함) | 재측정 예정 |
| Warm OCR 평균 | 재측정 예정 |
| OCR 최대 TPS | 재측정 예정 |
| 고대비 한국어 번호판 인식률 | 재측정 예정 |
| 평균 신뢰도 | 재측정 예정 |
주요 인사이트:
테스트 후 기록
전체 파이프라인의 각 단계별 소요 시간을 정리하면 다음과 같습니다.
Edge Device
↓ MQTT Publish (network)
RabbitMQ MQTT Plugin
↓ Internal routing
Django Subscriber (MQTT → DB → AMQP)
↓ (JSON parse + DB insert + AMQP publish)
RabbitMQ AMQP Queue
↓ (queue routing)
OCR Worker
↓ (GCS download + EasyOCR inference)
DB Update (completed)
↓
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: 전체 파이프라인의 지배적 병목 (GCS 다운로드 + EasyOCR 추론)
- 구체적 수치는 재측정 후 기록
개선 방안:
- GPU 인스턴스 전환: CPU → GPU로 OCR 추론 시간 단축 (5s → <1s 목표)
- 경량 OCR 모델: PaddleOCR 등 더 빠른 모델 검토
- 이미지 전처리: Edge Device에서 고대비 전처리 수행
단건 순차 테스트(4.4.2)에서 측정한 단위 처리 시간을 바탕으로, 20대 카메라가 동시 운영되는 실제 사용 패턴에서의 파이프라인 성능을 3단계 시나리오로 측정했습니다.
중요: 모든 MQTT 테스트는 실제 EasyOCR 환경에서 수행되었습니다 (OCR_MOCK=false).
테스트 구성
| 시나리오 | 카메라 수 | 발행 속도 | 지속시간 | 예상 메시지 | 목적 |
|---|---|---|---|---|---|
| Normal | 20대 | 1건/분/카메라 (0.33 msg/s) | 120초 | 40건 | 정상 운영 패턴 |
| Rush Hour | 20대 | 5건/분/카메라 (1.67 msg/s) | 120초 | 200건 | 러시아워 트래픽 |
| Burst | 20대 | 1건/초/카메라 (20 msg/s) | 60초 | 1,200건 | 극한 스트레스 |
공통 설정: 실제 GCS 번호판 이미지 10장 순환 사용, API 통계 폴링 + RabbitMQ 큐 깊이 모니터링, 파이프라인 완료 대기 타임아웃 300초
시나리오별 발행 결과
| 시나리오 | 발행 성공 | 발행 실패 | 평균 발행 지연 | 실측 발행 속도 |
|---|---|---|---|---|
| Normal | - | - | - | - |
| Rush Hour | - | - | - | - |
| Burst | - | - | - | - |
테스트 후 기록
시나리오별 파이프라인 처리 결과 (가설 vs 실측)
| 지표 | Normal 가설 | Normal 실측 | Rush Hour 가설 | Rush Hour 실측 | Burst 가설 | Burst 실측 |
|---|---|---|---|---|---|---|
| 발행 성공률 | 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 환경에서의 실측값은 테스트 후 기록.
Normal 시나리오 - OCR 큐 드레인 추이
시간(s) 완료 대기 OCR큐 FCM큐 실효 처리속도
──────────────────────────────────────────────
10 - - - - -
50 - - - - -
100 - - - - -
150 - - - - -
200 - - - - -
250 - - - - -
300 - - - - -
Rush Hour 시나리오 - OCR 큐 드레인 추이
시간(s) 완료 대기 OCR큐 FCM큐
──────────────────────────────────
10 - - - -
60 - - - -
120 - - - -
180 - - - -
240 - - - -
300 - - - -
Burst 시나리오 - OCR 큐 드레인 추이
시간(s) 완료 대기 OCR큐 FCM큐
──────────────────────────────────────
10 - - - -
60 - - - -
120 - - - -
180 - - - -
240 - - - -
300 - - - -
[스크린샷: RabbitMQ 대시보드 - OCR 큐 깊이 변화 (3 시나리오 전체 구간)]
[스크린샷: Celery Workers 대시보드 - OCR Task 처리 속도 (테스트 구간)]
[스크린샷: Container Metrics - speedcam-ocr CPU/Memory (테스트 구간)]
핵심 발견 — OCR 처리 속도 비교
| 지표 | 단건 (4.4.2) | Normal | Rush Hour | Burst |
|---|---|---|---|---|
| OCR 처리 속도 | - | - | - | - |
| OCR 큐 피크 | - | - | - | - |
| 파이프라인 완료율 | - | - | - | - |
| 부하 시 성능 저하 | - | - | - | - |
동시 부하 시 OCR 처리 속도 저하 원인 분석:
- 메모리 압박: e2-small(2GB)에서 EasyOCR 모델(1.5GB) + 큐 버퍼 → 메모리 여유분 소진
- GCS 다운로드 경합: 연속 다운로드 시 네트워크/API 지연 증가
- CPU 경합: OCR 추론 중 Celery 큐 관리 오버헤드
- 큐 백로그 누적: Rush Hour/Burst 후 큐 드레인에 수 시간 소요
결론: OCR Worker가 전체 파이프라인의 지배적 병목. OCR Worker 확장(수평 또는 GPU 전환)은 선택이 아닌 필수입니다.
| 항목 | 상세 |
|---|---|
| 테스트 일시 | 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 버킷 보간과 측정 범위 차이 때문입니다.
임계치 설정 기준: 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 → 사실상 처리 불가 |
| 메트릭 | 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)에 도달했음을 나타냅니다.
- 결과: 에러율 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 요청 발생 시 폴링 응답시간 급등 여부
- 결과: 에러율 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 스레드 포화 여부
- 결과: 전체 에러율 0.55% (40/7248)의 대부분이 stress_mixed에 집중
- 인사이트: 읽기와 OCR 쓰기가 혼합된 환경에서 OCR 동기 처리가 읽기 요청의 응답시간에도 영향을 미쳤습니다. 별도 커스텀 메트릭이 없어 전체 http_req_duration 기준으로 평가됩니다.
- 결과: 에러율 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 확인
- 결과: 에러율 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 확인
- 결과: 에러율 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배)로 치솟는 현상과 같은 근본 원인입니다.
| 항목 | 값 |
|---|---|
| 총 요청 수 | 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) |
- 동기 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 추이
Event Driven Architecture 전환을 통해 기존 모놀리식 구조의 모든 핵심 문제를 해결했습니다.
| 메트릭 | 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% | - | - |
| 메트릭 | v1 (동기 OCR) | v2 (비동기 EDA) | 개선율 | 비고 |
|---|---|---|---|---|
| 읽기 p95 | 351.21ms (전체 http p95) | - | - | 0→9 VUs, 별도 커스텀 메트릭 없음 |
| 쓰기 p95 | 24.2s (OCR POST p95) | - | - | v1:OCR POST / v2:차량등록 |
| 에러율 | 0.55% (전체) | - | - |
| 메트릭 | v1 (동기 OCR) | v2 (비동기 EDA) | 개선율 | 비고 |
|---|---|---|---|---|
| p95 응답시간 | 128.06ms | - | - | 0→15 VUs, 읽기 전용 |
| 에러율 | 0% | - | - | |
| 최대 RPS | - | - | - | 테스트 후 기록 |
| 메트릭 | 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실행 결과입니다.
| 비교 항목 | 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 처리 → 읽기 요청에 영향 미미 → 시스템 안정
| 항목 | 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별 확장 | 구조적 개선 |
graph LR
subgraph Before["기존 아키텍처"]
B1["Django<br/>(API + OCR)"]
B2["3초+ 응답"]
B3["HTTP 오버헤드"]
B4["장애 전파"]
style B1 fill:#ffcccc
style B2 fill:#ffcccc
style B3 fill:#ffcccc
style B4 fill:#ffcccc
end
subgraph After["Event Driven Architecture"]
A1["Django<br/>(API만)"]
A2["[실측값]ms 처리"]
A3["MQTT+AMQP"]
A4["장애 격리"]
style A1 fill:#90EE90
style A2 fill:#90EE90
style A3 fill:#90EE90
style A4 fill:#90EE90
end
Before -->|"아키텍처 전환"| After
| 기존 문제 | 해결 방법 | 효과 |
|---|---|---|
| OCR 동기 처리 | OCR Worker 분리 + AMQP 비동기 처리 | 이벤트 처리시간 대폭 단축 (재측정 예정) |
| Edge Device 블로킹 | MQTT QoS 1 + 즉시 ACK | 연속 감지 가능, 데이터 유실 방지 |
| HTTP IoT 통신 | MQTT 프로토콜 도입 | 경량 프로토콜, 메시지 보장, 오프라인 버퍼링 |
| 장애 전파 | 컴포넌트 분리 + 이벤트 큐 보존 | OCR 장애 시에도 API 정상 운영 |
총 7개의 커스텀 대시보드를 운영하여 시스템의 모든 계층을 모니터링합니다.
| 대시보드 | 용도 |
|---|---|
| k6 Prometheus Dashboard | HTTP API 메트릭 실시간 시각화 |
| System Overview | 전체 시스템 리소스 현황 |
| Container Metrics | Docker 컨테이너별 CPU/Memory/Network |
| MySQL Performance | 쿼리 성능, 커넥션, 슬로우 쿼리 |
| RabbitMQ Monitoring | 메시지 큐 깊이, 처리량, 컨슈머 |
| Celery Workers | Task 처리량, 지연 시간, 실패율 |
| Application Logs | Loki 기반 통합 로그 검색 |
[스크린샷: System Overview 대시보드 - 6개 인스턴스 CPU/Memory 전체 현황]
[스크린샷: MySQL Performance 대시보드 - 커넥션 수 변화 (부하 테스트 구간)]
총 11개 타겟 (All UP)
| 타겟 | 인스턴스 | 상태 |
|---|---|---|
| cAdvisor | speedcam-app | ✅ UP |
| cAdvisor | speedcam-db | ✅ UP |
| cAdvisor | speedcam-mq | ✅ UP |
| cAdvisor | speedcam-ocr | ✅ UP |
| cAdvisor | speedcam-alert | ✅ UP |
| cAdvisor | speedcam-mon | ✅ UP |
| django | speedcam-app | ✅ UP |
| mysql | speedcam-db | ✅ UP |
| rabbitmq | speedcam-mq | ✅ UP |
| celery | speedcam-ocr | ✅ UP |
| otel | speedcam-mon | ✅ UP |
[스크린샷: Prometheus → Status → Targets 페이지 (11개 타겟 All UP)]
총 16개 컨테이너 로그 수집 (Promtail → Loki)
- Django, Gunicorn, Celery Workers
- MySQL, RabbitMQ
- Traefik, Flower
- Prometheus, Grafana, Loki, Jaeger, OpenTelemetry Collector
각 컴포넌트별 최대 처리 성능과 병목 지점을 분석했습니다.
| 컴포넌트 | 이론값 | 실측값 | 근거 | 병목 요인 |
|---|---|---|---|---|
| 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 | - | 재측정 예정 | 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)에서 실행된 결과로 별도 클라이언트 사용 시 더 높을 수 있음. OCR Worker가 전체 파이프라인의 지배적 병목으로 예상.
현재 병목: OCR Worker
graph LR
A["HTTP API<br/>(재측정 예정)"] ~~~ B
B["MQTT Subscriber<br/>(재측정 예정)"] -->|"병목"| C["OCR Worker<br/>(재측정 예정)"]
C --> D["Alert Worker<br/>~100 msg/s (추정)"]
style C fill:#ff6666
병목 분석 (구조 기반):
- OCR Worker가 전체 파이프라인의 지배적 병목으로 예상
- e2-small(2GB)에서 EasyOCR concurrency=1만 가능 (메모리 제약)
- 동시 부하 시 메모리 압박 + GCS 경합으로 성능 저하 발생 예상
- 구체적 수치는 테스트 수행 후 기록
해결 방안:
| 방법 | 예상 개선 | 비용 | 난이도 |
|---|---|---|---|
| OCR 인스턴스 추가 (horizontal) | 처리량 N배 향상 | 저 | 낮음 |
| GPU 인스턴스 전환 | OCR 추론 시간 대폭 단축 (CPU 대비 5x+ 목표) | 중 | 중 |
| e2-medium 업그레이드 | 메모리 여유로 부하 시 성능 저하 완화 | 저 | 낮음 |
| 경량 OCR 모델 (PaddleOCR) | ~2-3x 빠름 | 저 | 중 |
| Edge 전처리 | 이미지 크기 감소 | 저 | 낮음 |
| 이슈 | 원인 | 해결 방법 |
|---|---|---|
| MQTT Subscriber Stale DB Connection | 장기 실행 스레드에서 MySQL 연결 만료 | close_old_connections() 추가로 해결 |
| OCR Worker OOM | EasyOCR 모델 × 4 workers = 6GB (e2-small 2GB 초과) | concurrency=1로 조정 |
| GCS 인증 | JSON 키 파일 없음 | ADC(메타데이터 서버) 활용으로 해결 |
| 이슈 | 현재 상태 | 영향도 | 개선 방안 |
|---|---|---|---|
| FCM 토큰 업데이트 API | PATCH 엔드포인트 0% 성공률 | 🔴 High | API endpoint 로직 수정 |
| OCR Worker 확장성 | 단일 워커, 메모리 제약으로 concurrency=1 | 🔴 High | GPU 인스턴스 또는 경량 OCR 모델 검토 |
| 모니터링 인스턴스 메모리 | 264MB 여유 (메모리 부족 위험) | 🟡 Medium | e2-medium 업그레이드 권장 |
| 이슈 | 현재 상태 | 영향도 | 개선 방안 |
|---|---|---|---|
| CONN_MAX_AGE 미설정 | 매 요청 새 DB 커넥션 | 🟡 Medium | 커넥션 풀링 설정 (성능 10-20% 개선 예상) |
| MQTT Subscriber 단일 스레드 | 병목 시 메시지 큐잉 | 🟡 Medium | 스레드풀 or 멀티 프로세스 검토 |
| speedcam-ocr 디스크 사용률 | 87% (17GB/20GB) | 🟡 Medium | 디스크 정리 또는 확장 |
| 항목 | 목표 | 예상 효과 |
|---|---|---|
| 읽기 복제본 추가 | MySQL 읽기 부하 분산 | 쿼리 성능 향상 |
| Redis 캐싱 | 통계 조회 캐싱 | API 응답 속도 향상 |
| Celery Beat 추가 | 주기적 작업 자동화 | 운영 효율성 향상 |
SpeedCam 시스템은 기존 동기식 HTTP 기반 모놀리식 아키텍처에서 Event Driven Architecture로 성공적으로 전환하였습니다.
정량적 성과:
| 지표 | v1 (동기 OCR) | v2 (비동기 EDA) | 개선 | 근거 |
|---|---|---|---|---|
| 이벤트 처리 시간 | 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 성공) |
정성적 성과:
- 장애 격리: OCR 장애 시에도 API 정상 운영 가능
- 독립 확장: Worker별 독립적 스케일 아웃
- 완전한 관측성: Prometheus + Grafana + Loki + Jaeger 통합 모니터링
- IoT 최적화: MQTT QoS 1로 메시지 전달 보장
- Choreography 패턴: OCR Worker → Alert Worker 직접 domain event 전달 (Main Service 불개입)
- FCM 토큰 업데이트 API 버그 수정
-
CONN_MAX_AGE설정으로 DB 커넥션 풀링 활성화 - speedcam-ocr 디스크 정리
- OCR Worker GPU 인스턴스 전환 (5s → <1s 목표)
- 모니터링 인스턴스 e2-medium 업그레이드
- MQTT Subscriber 멀티스레딩 구현
- Redis 캐싱 레이어 추가
- MySQL 읽기 복제본 구성
- Celery Beat 스케줄러 추가
- 이미지 전처리 파이프라인 구축
SpeedCam 프로젝트는 Event Driven Architecture를 통해 기존 모놀리식 구조의 근본적 한계를 극복하고, 실시간 IoT 시스템으로서 요구되는 높은 응답성, 메시지 보장, 장애 격리를 모두 달성했습니다.
특히 완전한 비동기 처리, Choreography 패턴 기반 도메인 이벤트 흐름 (OCR Worker → Alert Worker Kombu Consumer), 컴포넌트별 독립 확장이라는 핵심 목표를 성공적으로 구현하여, 프로덕션 환경에서 안정적으로 운영 가능한 시스템으로 발전했습니다.
앞으로 OCR Worker GPU 전환과 DB 커넥션 풀링 최적화를 통해 더욱 빠르고 효율적인 시스템으로 발전할 것으로 기대됩니다. 정량적 성과 수치는 재측정 후 기록됩니다.
문서 버전: 3.0 최종 수정일: 2026-02-19 테스트 일시: 2026-02-12 (k6 HTTP 4시나리오 + MQTT 3시나리오) 작성자: SpeedCam Backend Team 관련 문서: ARCHITECTURE_COMPARISON.md