← 블로그 홈

Django Ninja와 Django RQ 완전 가이드: 비동기 API와 백그라운드 작업 마스터하기

현대 웹 애플리케이션에서는 즉시 처리되지 않아도 되는 작업들을 백그라운드에서 비동기적으로 처리하는 것이 중요합니다. Django Ninja와 Django RQ를 함께 사용하면 고성능 API와 효율적인 백그라운드 작업 시스템을 구축할 수 있습니다. 이 가이드에서는...

현대 웹 애플리케이션에서는 즉시 처리되지 않아도 되는 작업들을 백그라운드에서 비동기적으로 처리하는 것이 중요합니다. Django Ninja와 Django RQ를 함께 사용하면 고성능 API와 효율적인 백그라운드 작업 시스템을 구축할 수 있습니다. 이 가이드에서는 단계별로 완전한 구현 방법을 알아보겠습니다.

🎯 Django RQ란?

Django RQ는 Python RQ(Redis Queue)를 Django에 통합한 라이브러리입니다. Redis를 백엔드로 사용하여 간단하고 빠른 백그라운드 작업 처리를 제공합니다.

Django RQ vs Celery 비교

특징 Django RQ Celery
복잡성 간단, 최소 설정 복잡, 많은 설정 옵션
브로커 Redis만 지원 Redis, RabbitMQ, 기타
성능 가볍고 빠름 고성능, 확장성
기능 기본적인 큐 기능 고급 스케줄링, 체인 등
학습 곡선 낮음 높음
적합한 용도 중소규모 프로젝트 대규모 엔터프라이즈

핵심 장점

  • 간단한 설정: 복잡한 설정 없이 빠른 시작
  • Redis 기반: 고속 메모리 기반 큐 시스템
  • Django 통합: Django ORM과 완벽 호환
  • 모니터링: 내장 웹 대시보드 제공
  • 실패 처리: 자동 재시도와 실패 로그
  • 스케줄링: 지연 실행과 반복 작업 지원

주요 사용 사례

  • 이메일 발송
  • 이미지/비디오 처리
  • 데이터 분석 및 리포트 생성
  • 외부 API 호출
  • 파일 업로드/다운로드 처리
  • 알림 발송

📦 1단계: 환경 설정 및 설치

필요한 패키지 설치

# 기본 패키지 설치
pip install django-ninja django-rq redis

# 개발/운영 환경에 따른 추가 패키지
pip install rq-dashboard  # 웹 대시보드
pip install django-extensions  # 개발 도구

Redis 설치 및 실행

# Ubuntu/Debian
sudo apt-get update
sudo apt-get install redis-server

# macOS (Homebrew)
brew install redis

# Docker로 실행
docker run -d -p 6379:6379 --name redis redis:latest

# Redis 서버 시작
redis-server

# Redis 연결 테스트
redis-cli ping
# PONG 응답이 나오면 정상

Django 설정

# settings.py
import os
from django.core.management.utils import get_random_secret_key

# 기본 Django 설정
SECRET_KEY = os.environ.get('SECRET_KEY', get_random_secret_key())
DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    
    # 추가 앱
    'django_rq',
    'rq_dashboard',  # 선택사항: 웹 대시보드
    
    # 프로젝트 앱
    'tasks',  # 백그라운드 작업 앱
    'api',    # API 앱
]

# Redis 및 RQ 설정
RQ_QUEUES = {
    'default': {
        'HOST': os.environ.get('REDIS_HOST', 'localhost'),
        'PORT': int(os.environ.get('REDIS_PORT', 6379)),
        'DB': int(os.environ.get('REDIS_DB', 0)),
        'PASSWORD': os.environ.get('REDIS_PASSWORD', None),
        'DEFAULT_TIMEOUT': 360,
        'CONNECTION_CLASS': 'redis.connection.Connection',
    },
    'high': {  # 높은 우선순위 큐
        'HOST': os.environ.get('REDIS_HOST', 'localhost'),
        'PORT': int(os.environ.get('REDIS_PORT', 6379)),
        'DB': int(os.environ.get('REDIS_DB', 0)),
        'PASSWORD': os.environ.get('REDIS_PASSWORD', None),
        'DEFAULT_TIMEOUT': 500,
    },
    'low': {   # 낮은 우선순위 큐
        'HOST': os.environ.get('REDIS_HOST', 'localhost'),
        'PORT': int(os.environ.get('REDIS_PORT', 6379)),
        'DB': int(os.environ.get('REDIS_DB', 0)),
        'PASSWORD': os.environ.get('REDIS_PASSWORD', None),
        'DEFAULT_TIMEOUT': 500,
    }
}

# RQ 추가 설정
RQ_SHOW_ADMIN_LINK = True  # Django Admin에 RQ 링크 표시

# 로깅 설정
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'file': {
            'level': 'INFO',
            'class': 'logging.FileHandler',
            'filename': 'rq_jobs.log',
            'formatter': 'verbose',
        },
        'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'rq.worker': {
            'handlers': ['file', 'console'],
            'level': 'INFO',
            'propagate': False,
        },
    },
}

URL 설정

# urls.py
from django.contrib import admin
from django.urls import path, include
from ninja import NinjaAPI
from api.views import router as api_router

# Django Ninja API 인스턴스 생성
api = NinjaAPI(
    title="Django RQ API",
    description="Django Ninja와 RQ를 활용한 백그라운드 작업 API",
    version="1.0.0"
)

# API 라우터 등록
api.add_router("/tasks/", api_router)

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', api.urls),
    path('django-rq/', include('django_rq.urls')),  # RQ 관리 인터페이스
    path('rq-dashboard/', include('rq_dashboard.urls')),  # RQ 대시보드 (선택사항)
]

🔧 2단계: Django Ninja와 RQ 연동

작업 정의

# tasks/jobs.py
import time
import logging
import requests
from typing import Optional, Dict, Any
from django.core.mail import send_mail
from django.contrib.auth.models import User
from django.conf import settings
from PIL import Image
import io
import os

logger = logging.getLogger(__name__)

# 이메일 발송 작업
def send_email_task(
    subject: str,
    message: str,
    recipient_list: list,
    from_email: Optional[str] = None,
    html_message: Optional[str] = None
) -> Dict[str, Any]:
    """
    이메일 발송 백그라운드 작업
    """
    try:
        logger.info(f"이메일 발송 시작: {subject} -> {recipient_list}")
        
        result = send_mail(
            subject=subject,
            message=message,
            from_email=from_email or settings.DEFAULT_FROM_EMAIL,
            recipient_list=recipient_list,
            html_message=html_message,
            fail_silently=False
        )
        
        logger.info(f"이메일 발송 완료: {result}개 발송")
        return {
            'status': 'success',
            'sent_count': result,
            'recipients': recipient_list
        }
        
    except Exception as e:
        logger.error(f"이메일 발송 실패: {str(e)}")
        return {
            'status': 'failed',
            'error': str(e),
            'recipients': recipient_list
        }

# 이미지 처리 작업
def process_image_task(
    image_url: str,
    operations: list,
    output_path: str
) -> Dict[str, Any]:
    """
    이미지 다운로드 및 처리 작업
    """
    try:
        logger.info(f"이미지 처리 시작: {image_url}")
        
        # 이미지 다운로드
        response = requests.get(image_url, timeout=30)
        response.raise_for_status()
        
        # PIL Image로 변환
        image = Image.open(io.BytesIO(response.content))
        original_size = image.size
        
        # 작업 수행
        for operation in operations:
            if operation['type'] == 'resize':
                size = operation['size']
                image = image.resize(size, Image.Resampling.LANCZOS)
            elif operation['type'] == 'rotate':
                angle = operation['angle']
                image = image.rotate(angle, expand=True)
            elif operation['type'] == 'convert':
                format_type = operation['format']
                if format_type.upper() == 'JPEG' and image.mode == 'RGBA':
                    # RGBA -> RGB 변환 (JPEG는 투명도 미지원)
                    rgb_image = Image.new('RGB', image.size, (255, 255, 255))
                    rgb_image.paste(image, mask=image.split()[-1])
                    image = rgb_image
        
        # 이미지 저장
        os.makedirs(os.path.dirname(output_path), exist_ok=True)
        image.save(output_path, quality=95, optimize=True)
        
        result = {
            'status': 'success',
            'original_size': original_size,
            'final_size': image.size,
            'output_path': output_path,
            'operations_applied': len(operations)
        }
        
        logger.info(f"이미지 처리 완료: {result}")
        return result
        
    except Exception as e:
        logger.error(f"이미지 처리 실패: {str(e)}")
        return {
            'status': 'failed',
            'error': str(e),
            'image_url': image_url
        }

# 데이터 분석 작업
def analyze_data_task(
    data_source: str,
    analysis_type: str,
    parameters: Dict[str, Any]
) -> Dict[str, Any]:
    """
    데이터 분석 백그라운드 작업
    """
    try:
        logger.info(f"데이터 분석 시작: {analysis_type}")
        
        # 시뮬레이션: 복잡한 데이터 분석
        import random
        time.sleep(random.randint(5, 15))  # 5-15초 처리 시간 시뮬레이션
        
        # 가상의 분석 결과
        results = {
            'status': 'success',
            'analysis_type': analysis_type,
            'data_source': data_source,
            'records_processed': random.randint(1000, 10000),
            'insights': [
                f"패턴 A: {random.randint(1, 100)}% 증가",
                f"패턴 B: {random.randint(1, 100)}% 감소",
                f"이상치: {random.randint(1, 50)}개 발견"
            ],
            'score': round(random.uniform(0.1, 1.0), 3),
            'parameters_used': parameters
        }
        
        logger.info(f"데이터 분석 완료: {results['records_processed']}개 레코드 처리")
        return results
        
    except Exception as e:
        logger.error(f"데이터 분석 실패: {str(e)}")
        return {
            'status': 'failed',
            'error': str(e),
            'analysis_type': analysis_type
        }

# 외부 API 호출 작업
def fetch_external_data_task(
    api_url: str,
    headers: Optional[Dict[str, str]] = None,
    timeout: int = 30
) -> Dict[str, Any]:
    """
    외부 API 데이터 수집 작업
    """
    try:
        logger.info(f"외부 API 호출 시작: {api_url}")
        
        response = requests.get(
            api_url,
            headers=headers or {},
            timeout=timeout
        )
        response.raise_for_status()
        
        data = response.json()
        
        result = {
            'status': 'success',
            'url': api_url,
            'status_code': response.status_code,
            'data_size': len(str(data)),
            'response_time': response.elapsed.total_seconds(),
            'data': data
        }
        
        logger.info(f"외부 API 호출 완료: {result['data_size']} bytes")
        return result
        
    except Exception as e:
        logger.error(f"외부 API 호출 실패: {str(e)}")
        return {
            'status': 'failed',
            'error': str(e),
            'url': api_url
        }

# 사용자 알림 작업
def send_user_notification_task(
    user_id: int,
    notification_type: str,
    title: str,
    message: str,
    metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
    """
    사용자 알림 발송 작업
    """
    try:
        logger.info(f"사용자 알림 발송: User {user_id}, Type: {notification_type}")
        
        user = User.objects.get(id=user_id)
        
        # 여기서 실제 알림 시스템과 연동
        # 예: FCM, WebSocket, 이메일 등
        
        # 시뮬레이션: 알림 발송 처리
        time.sleep(2)
        
        result = {
            'status': 'success',
            'user_id': user_id,
            'username': user.username,
            'notification_type': notification_type,
            'title': title,
            'sent_at': time.time(),
            'metadata': metadata or {}
        }
        
        logger.info(f"사용자 알림 발송 완료: {user.username}")
        return result
        
    except User.DoesNotExist:
        error_msg = f"사용자를 찾을 수 없음: {user_id}"
        logger.error(error_msg)
        return {
            'status': 'failed',
            'error': error_msg,
            'user_id': user_id
        }
    except Exception as e:
        logger.error(f"사용자 알림 발송 실패: {str(e)}")
        return {
            'status': 'failed',
            'error': str(e),
            'user_id': user_id
        }

Pydantic 스키마 정의

# api/schemas.py
from ninja import Schema
from pydantic import Field, validator
from typing import Optional, Dict, Any, List
from datetime import datetime
from enum import Enum

class JobStatus(str, Enum):
    QUEUED = "queued"
    STARTED = "started"
    FINISHED = "finished"
    FAILED = "failed"
    DEFERRED = "deferred"
    CANCELED = "canceled"

class QueueName(str, Enum):
    DEFAULT = "default"
    HIGH = "high"
    LOW = "low"

# 이메일 작업 스키마
class EmailJobSchema(Schema):
    subject: str = Field(..., min_length=1, max_length=200)
    message: str = Field(..., min_length=1)
    recipient_list: List[str] = Field(..., min_items=1)
    from_email: Optional[str] = None
    html_message: Optional[str] = None
    queue: QueueName = QueueName.DEFAULT
    delay: Optional[int] = Field(None, ge=0, description="지연 시간(초)")
    
    @validator('recipient_list')
    def validate_emails(cls, v):
        import re
        email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        for email in v:
            if not re.match(email_pattern, email):
                raise ValueError(f'Invalid email format: {email}')
        return v

# 이미지 처리 작업 스키마
class ImageOperation(Schema):
    type: str = Field(..., regex="^(resize|rotate|convert)$")
    size: Optional[List[int]] = Field(None, min_items=2, max_items=2)
    angle: Optional[float] = None
    format: Optional[str] = Field(None, regex="^(JPEG|PNG|WEBP)$")

class ImageJobSchema(Schema):
    image_url: str = Field(..., regex=r'^https?://.+')
    operations: List[ImageOperation] = Field(..., min_items=1)
    output_filename: str = Field(..., min_length=1)
    queue: QueueName = QueueName.DEFAULT
    delay: Optional[int] = Field(None, ge=0)

# 데이터 분석 작업 스키마
class DataAnalysisJobSchema(Schema):
    data_source: str = Field(..., min_length=1)
    analysis_type: str = Field(..., regex="^(trend|correlation|anomaly|prediction)$")
    parameters: Dict[str, Any] = Field(default_factory=dict)
    queue: QueueName = QueueName.LOW  # 분석 작업은 낮은 우선순위
    delay: Optional[int] = Field(None, ge=0)

# 외부 API 호출 스키마
class ExternalAPIJobSchema(Schema):
    api_url: str = Field(..., regex=r'^https?://.+')
    headers: Optional[Dict[str, str]] = None
    timeout: int = Field(30, ge=1, le=300)
    queue: QueueName = QueueName.DEFAULT
    delay: Optional[int] = Field(None, ge=0)

# 알림 작업 스키마
class NotificationJobSchema(Schema):
    user_id: int = Field(..., gt=0)
    notification_type: str = Field(..., regex="^(email|push|sms|in_app)$")
    title: str = Field(..., min_length=1, max_length=100)
    message: str = Field(..., min_length=1, max_length=500)
    metadata: Optional[Dict[str, Any]] = None
    queue: QueueName = QueueName.HIGH  # 알림은 높은 우선순위
    delay: Optional[int] = Field(None, ge=0)

# 작업 응답 스키마
class JobResponseSchema(Schema):
    job_id: str
    status: JobStatus
    queue: str
    created_at: datetime
    enqueued_at: Optional[datetime] = None
    started_at: Optional[datetime] = None
    ended_at: Optional[datetime] = None
    result: Optional[Dict[str, Any]] = None
    error: Optional[str] = None
    timeout: int
    
class JobListSchema(Schema):
    jobs: List[JobResponseSchema]
    total: int
    queue: str
    
class QueueStatsSchema(Schema):
    name: str
    count: int
    failed_count: int
    deferred_count: int
    started_count: int
    finished_count: int

API 엔드포인트 구현

# api/views.py
from ninja import Router
from django.http import JsonResponse
from django_rq import get_queue, get_connection
from rq import Queue
from rq.job import Job
from rq.registry import (
    StartedJobRegistry, 
    FinishedJobRegistry, 
    FailedJobRegistry,
    DeferredJobRegistry
)
from typing import List, Optional
import json
from datetime import datetime

from tasks.jobs import (
    send_email_task,
    process_image_task,
    analyze_data_task,
    fetch_external_data_task,
    send_user_notification_task
)
from .schemas import *

router = Router()

def job_to_dict(job: Job) -> dict:
    """RQ Job 객체를 딕셔너리로 변환"""
    return {
        'job_id': job.id,
        'status': job.get_status(),
        'queue': job.origin,
        'created_at': job.created_at.isoformat() if job.created_at else None,
        'enqueued_at': job.enqueued_at.isoformat() if job.enqueued_at else None,
        'started_at': job.started_at.isoformat() if job.started_at else None,
        'ended_at': job.ended_at.isoformat() if job.ended_at else None,
        'result': job.result,
        'error': str(job.exc_info) if job.exc_info else None,
        'timeout': job.timeout or 180
    }

@router.post("/email", response=JobResponseSchema, tags=["Jobs"])
async def send_email_job(request, data: EmailJobSchema):
    """이메일 발송 작업 생성"""
    queue = get_queue(data.queue.value)
    
    job = queue.enqueue(
        send_email_task,
        subject=data.subject,
        message=data.message,
        recipient_list=data.recipient_list,
        from_email=data.from_email,
        html_message=data.html_message,
        job_timeout='5m',
        delay=data.delay
    )
    
    return JobResponseSchema(**job_to_dict(job))

@router.post("/image", response=JobResponseSchema, tags=["Jobs"])
async def process_image_job(request, data: ImageJobSchema):
    """이미지 처리 작업 생성"""
    queue = get_queue(data.queue.value)
    
    # 출력 경로 생성
    output_path = f"media/processed/{data.output_filename}"
    operations = [op.dict() for op in data.operations]
    
    job = queue.enqueue(
        process_image_task,
        image_url=data.image_url,
        operations=operations,
        output_path=output_path,
        job_timeout='10m',
        delay=data.delay
    )
    
    return JobResponseSchema(**job_to_dict(job))

@router.post("/analysis", response=JobResponseSchema, tags=["Jobs"])
async def create_analysis_job(request, data: DataAnalysisJobSchema):
    """데이터 분석 작업 생성"""
    queue = get_queue(data.queue.value)
    
    job = queue.enqueue(
        analyze_data_task,
        data_source=data.data_source,
        analysis_type=data.analysis_type,
        parameters=data.parameters,
        job_timeout='30m',
        delay=data.delay
    )
    
    return JobResponseSchema(**job_to_dict(job))

@router.post("/external-api", response=JobResponseSchema, tags=["Jobs"])
async def fetch_external_data_job(request, data: ExternalAPIJobSchema):
    """외부 API 호출 작업 생성"""
    queue = get_queue(data.queue.value)
    
    job = queue.enqueue(
        fetch_external_data_task,
        api_url=data.api_url,
        headers=data.headers,
        timeout=data.timeout,
        job_timeout='5m',
        delay=data.delay
    )
    
    return JobResponseSchema(**job_to_dict(job))

@router.post("/notification", response=JobResponseSchema, tags=["Jobs"])
async def send_notification_job(request, data: NotificationJobSchema):
    """사용자 알림 작업 생성"""
    queue = get_queue(data.queue.value)
    
    job = queue.enqueue(
        send_user_notification_task,
        user_id=data.user_id,
        notification_type=data.notification_type,
        title=data.title,
        message=data.message,
        metadata=data.metadata,
        job_timeout='2m',
        delay=data.delay
    )
    
    return JobResponseSchema(**job_to_dict(job))

@router.get("/job/{job_id}", response=JobResponseSchema, tags=["Monitoring"])
async def get_job_status(request, job_id: str):
    """특정 작업 상태 조회"""
    try:
        connection = get_connection()
        job = Job.fetch(job_id, connection=connection)
        return JobResponseSchema(**job_to_dict(job))
    except Exception as e:
        return JsonResponse({"error": f"Job not found: {str(e)}"}, status=404)

@router.delete("/job/{job_id}", tags=["Monitoring"])
async def cancel_job(request, job_id: str):
    """작업 취소"""
    try:
        connection = get_connection()
        job = Job.fetch(job_id, connection=connection)
        job.cancel()
        return {"message": f"Job {job_id} cancelled successfully"}
    except Exception as e:
        return JsonResponse({"error": f"Failed to cancel job: {str(e)}"}, status=400)

@router.get("/jobs", response=JobListSchema, tags=["Monitoring"])
async def list_jobs(
    request, 
    queue: QueueName = QueueName.DEFAULT,
    status: Optional[JobStatus] = None,
    limit: int = 50
):
    """작업 목록 조회"""
    connection = get_connection()
    q = Queue(queue.value, connection=connection)
    
    jobs = []
    
    if status is None or status == JobStatus.QUEUED:
        jobs.extend(q.jobs)
    
    if status is None or status == JobStatus.STARTED:
        started_registry = StartedJobRegistry(queue.value, connection=connection)
        for job_id in started_registry.get_job_ids():
            try:
                job = Job.fetch(job_id, connection=connection)
                jobs.append(job)
            except:
                continue
    
    if status is None or status == JobStatus.FINISHED:
        finished_registry = FinishedJobRegistry(queue.value, connection=connection)
        for job_id in finished_registry.get_job_ids()[:limit]:
            try:
                job = Job.fetch(job_id, connection=connection)
                jobs.append(job)
            except:
                continue
    
    if status is None or status == JobStatus.FAILED:
        failed_registry = FailedJobRegistry(queue.value, connection=connection)
        for job_id in failed_registry.get_job_ids()[:limit]:
            try:
                job = Job.fetch(job_id, connection=connection)
                jobs.append(job)
            except:
                continue
    
    # 작업을 딕셔너리로 변환
    job_dicts = [job_to_dict(job) for job in jobs[:limit]]
    
    return JobListSchema(
        jobs=job_dicts,
        total=len(job_dicts),
        queue=queue.value
    )

@router.get("/queues/stats", response=List[QueueStatsSchema], tags=["Monitoring"])
async def get_queue_stats(request):
    """모든 큐의 통계 정보"""
    connection = get_connection()
    stats = []
    
    for queue_name in ['default', 'high', 'low']:
        queue = Queue(queue_name, connection=connection)
        
        # 각 레지스트리에서 작업 수 계산
        started_registry = StartedJobRegistry(queue_name, connection=connection)
        finished_registry = FinishedJobRegistry(queue_name, connection=connection)
        failed_registry = FailedJobRegistry(queue_name, connection=connection)
        deferred_registry = DeferredJobRegistry(queue_name, connection=connection)
        
        stats.append(QueueStatsSchema(
            name=queue_name,
            count=len(queue),
            failed_count=len(failed_registry),
            deferred_count=len(deferred_registry),
            started_count=len(started_registry),
            finished_count=len(finished_registry)
        ))
    
    return stats

@router.post("/queues/{queue_name}/clear", tags=["Management"])
async def clear_queue(request, queue_name: str):
    """큐 비우기"""
    try:
        queue = get_queue(queue_name)
        cleared_count = queue.empty()
        return {"message": f"Cleared {cleared_count} jobs from {queue_name} queue"}
    except Exception as e:
        return JsonResponse({"error": f"Failed to clear queue: {str(e)}"}, status=400)

@router.post("/jobs/retry-failed", tags=["Management"])
async def retry_failed_jobs(request, queue: QueueName = QueueName.DEFAULT):
    """실패한 작업 재시도"""
    connection = get_connection()
    failed_registry = FailedJobRegistry(queue.value, connection=connection)
    
    retry_count = 0
    for job_id in failed_registry.get_job_ids():
        try:
            job = Job.fetch(job_id, connection=connection)
            job.retry()
            retry_count += 1
        except:
            continue
    
    return {"message": f"Retried {retry_count} failed jobs in {queue.value} queue"}

📊 3단계: 모니터링 및 관리

RQ Dashboard 설정

# rq_dashboard_settings.py
RQ_DASHBOARD_SETTINGS = {
    'REDIS_URL': 'redis://localhost:6379/0',
    'REDIS_PASSWORD': None,
    'REDIS_DB': 0,
    'POLL_INTERVAL': 2500,  # 2.5초마다 업데이트
    'DELETE_JOBS': True,    # 작업 삭제 허용
    'WEB_BACKGROUND': 'white',
}

커스텀 관리 명령어

# management/commands/rq_monitor.py
from django.core.management.base import BaseCommand
from django_rq import get_connection, get_queue
from rq import Worker
import time

class Command(BaseCommand):
    help = 'RQ 큐 모니터링 명령어'
    
    def add_arguments(self, parser):
        parser.add_argument('--queue', type=str, default='default', help='모니터링할 큐 이름')
        parser.add_argument('--interval', type=int, default=5, help='모니터링 간격(초)')
    
    def handle(self, *args, **options):
        queue_name = options['queue']
        interval = options['interval']
        
        connection = get_connection()
        queue = get_queue(queue_name)
        
        self.stdout.write(f"Monitoring queue: {queue_name}")
        self.stdout.write(f"Update interval: {interval} seconds")
        self.stdout.write("-" * 50)
        
        try:
            while True:
                # 큐 상태 출력
                queued_jobs = len(queue)
                workers = Worker.all(connection=connection)
                active_workers = [w for w in workers if w.state == 'busy']
                
                self.stdout.write(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}")
                self.stdout.write(f"Queued jobs: {queued_jobs}")
                self.stdout.write(f"Active workers: {len(active_workers)}")
                self.stdout.write(f"Total workers: {len(workers)}")
                
                if active_workers:
                    self.stdout.write("Active jobs:")
                    for worker in active_workers:
                        current_job = worker.get_current_job()
                        if current_job:
                            self.stdout.write(f"  - {current_job.func_name} (ID: {current_job.id})")
                
                self.stdout.write("-" * 50)
                time.sleep(interval)
                
        except KeyboardInterrupt:
            self.stdout.write("\nMonitoring stopped.")

건강 상태 체크 API

# api/health.py
from ninja import Router
from django_rq import get_connection, get_queue
from rq import Worker
import redis

health_router = Router()

@health_router.get("/health", tags=["Health"])
async def health_check(request):
    """시스템 건강 상태 체크"""
    try:
        # Redis 연결 테스트
        connection = get_connection()
        connection.ping()
        
        # 큐 상태 체크
        queues_status = {}
        for queue_name in ['default', 'high', 'low']:
            queue = get_queue(queue_name)
            queues_status[queue_name] = {
                'jobs_count': len(queue),
                'is_empty': queue.is_empty()
            }
        
        # 워커 상태 체크
        workers = Worker.all(connection=connection)
        workers_status = {
            'total': len(workers),
            'active': len([w for w in workers if w.state == 'busy']),
            'idle': len([w for w in workers if w.state == 'idle'])
        }
        
        return {
            'status': 'healthy',
            'redis': 'connected',
            'queues': queues_status,
            'workers': workers_status,
            'timestamp': time.time()
        }
        
    except redis.ConnectionError:
        return {
            'status': 'unhealthy',
            'redis': 'disconnected',
            'error': 'Redis connection failed'
        }
    except Exception as e:
        return {
            'status': 'unhealthy',
            'error': str(e)
        }

⚡ 4단계: 성능 최적화

워커 설정 최적화

# worker_config.py
import os
from rq import Worker
from django_rq import get_connection

class OptimizedWorker(Worker):
    """최적화된 RQ 워커"""
    
    def __init__(self, *args, **kwargs):
        # 워커별 설정
        kwargs.setdefault('default_result_ttl', 500)  # 결과 보존 시간
        kwargs.setdefault('default_worker_ttl', 420)  # 워커 TTL
        kwargs.setdefault('default_job_timeout', 180)  # 기본 작업 타임아웃
        
        super().__init__(*args, **kwargs)
    
    def work(self, *args, **kwargs):
        """메모리 사용량 모니터링과 함께 작업 처리"""
        import psutil
        process = psutil.Process(os.getpid())
        
        def log_memory_usage():
            memory_info = process.memory_info()
            memory_mb = memory_info.rss / 1024 / 1024
            if memory_mb > 500:  # 500MB 초과시 경고
                self.log.warning(f"High memory usage: {memory_mb:.1f}MB")
        
        # 작업 처리 전후 메모리 사용량 체크
        original_perform_job = self.perform_job
        
        def perform_job_with_monitoring(job, queue):
            log_memory_usage()
            result = original_perform_job(job, queue)
            log_memory_usage()
            return result
        
        self.perform_job = perform_job_with_monitoring
        
        return super().work(*args, **kwargs)

배치 작업 처리

# tasks/batch_jobs.py
from typing import List, Dict, Any
from django_rq import get_queue
import time

def process_batch_emails(email_batches: List[Dict[str, Any]]) -> Dict[str, Any]:
    """이메일 배치 처리"""
    results = []
    failed_count = 0
    
    for batch in email_batches:
        try:
            result = send_email_task(
                subject=batch['subject'],
                message=batch['message'],
                recipient_list=batch['recipients'],
                from_email=batch.get('from_email'),
                html_message=batch.get('html_message')
            )
            results.append(result)
            
            if result['status'] == 'failed':
                failed_count += 1
                
        except Exception as e:
            failed_count += 1
            results.append({
                'status': 'failed',
                'error': str(e),
                'batch': batch
            })
    
    return {
        'total_batches': len(email_batches),
        'successful': len(email_batches) - failed_count,
        'failed': failed_count,
        'results': results
    }

# 배치 작업 API 엔드포인트
@router.post("/batch/emails", response=JobResponseSchema, tags=["Batch"])
async def process_batch_emails_job(request, batches: List[EmailJobSchema]):
    """이메일 배치 처리 작업"""
    queue = get_queue('low')  # 배치 작업은 낮은 우선순위
    
    # 스키마를 딕셔너리로 변환
    batch_data = [batch.dict() for batch in batches]
    
    job = queue.enqueue(
        process_batch_emails,
        email_batches=batch_data,
        job_timeout='30m'
    )
    
    return JobResponseSchema(**job_to_dict(job))

메모리 최적화

# utils/memory_optimization.py
import gc
import psutil
import os
from functools import wraps

def memory_monitor(threshold_mb: int = 300):
    """메모리 사용량 모니터링 데코레이터"""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # 작업 전 메모리 사용량
            process = psutil.Process(os.getpid())
            memory_before = process.memory_info().rss / 1024 / 1024
            
            try:
                result = func(*args, **kwargs)
                
                # 작업 후 메모리 사용량
                memory_after = process.memory_info().rss / 1024 / 1024
                memory_used = memory_after - memory_before
                
                # 임계값 초과시 가비지 컬렉션 실행
                if memory_after > threshold_mb:
                    gc.collect()
                    memory_final = process.memory_info().rss / 1024 / 1024
                    print(f"Memory usage: {memory_before:.1f}MB -> {memory_after:.1f}MB -> {memory_final:.1f}MB")
                
                return result
                
            except Exception as e:
                # 예외 발생시에도 메모리 정리
                gc.collect()
                raise e
                
        return wrapper
    return decorator

# 사용 예제
@memory_monitor(threshold_mb=400)
def memory_intensive_task(data_size: int):
    """메모리 집약적 작업 예제"""
    large_data = [i for i in range(data_size)]
    processed_data = [x * 2 for x in large_data]
    return len(processed_data)

🚀 5단계: 배포 및 운영

Docker 설정

# Dockerfile.worker
FROM python:3.11-slim

WORKDIR /app

# 시스템 패키지 설치
RUN apt-get update && apt-get install -y \
    gcc \
    g++ \
    && rm -rf /var/lib/apt/lists/*

# Python 패키지 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 애플리케이션 코드 복사
COPY . .

# RQ 워커 실행 스크립트
COPY scripts/start-worker.sh /start-worker.sh
RUN chmod +x /start-worker.sh

CMD ["/start-worker.sh"]
# scripts/start-worker.sh
#!/bin/bash

# 환경 변수 설정
export DJANGO_SETTINGS_MODULE=project.settings.production

# Django 설정 확인
python manage.py check

# RQ 워커 시작
echo "Starting RQ worker for queue: ${QUEUE_NAME:-default}"
python manage.py rqworker ${QUEUE_NAME:-default} \
    --worker-class utils.memory_optimization.OptimizedWorker \
    --verbosity 2

Docker Compose 설정

# docker-compose.yml
version: '3.8'

services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    restart: unless-stopped

  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DJANGO_SETTINGS_MODULE=project.settings.production
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    depends_on:
      - redis
    restart: unless-stopped

  worker-default:
    build:
      context: .
      dockerfile: Dockerfile.worker
    environment:
      - DJANGO_SETTINGS_MODULE=project.settings.production
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - QUEUE_NAME=default
    depends_on:
      - redis
    restart: unless-stopped
    scale: 2

  worker-high:
    build:
      context: .
      dockerfile: Dockerfile.worker
    environment:
      - DJANGO_SETTINGS_MODULE=project.settings.production
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - QUEUE_NAME=high
    depends_on:
      - redis
    restart: unless-stopped
    scale: 1

  worker-low:
    build:
      context: .
      dockerfile: Dockerfile.worker
    environment:
      - DJANGO_SETTINGS_MODULE=project.settings.production
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - QUEUE_NAME=low
    depends_on:
      - redis
    restart: unless-stopped
    scale: 3

  rq-dashboard:
    image: eoranged/rq-dashboard
    ports:
      - "9181:9181"
    environment:
      - RQ_DASHBOARD_REDIS_URL=redis://redis:6379
    depends_on:
      - redis
    restart: unless-stopped

volumes:
  redis_data:

모니터링 및 알림

# monitoring/alerts.py
import smtplib
from email.mime.text import MimeType
from django.conf import settings
from django_rq import get_connection
from rq import Worker
import time

class RQMonitor:
    def __init__(self):
        self.connection = get_connection()
        self.alert_thresholds = {
            'queue_size': 100,      # 큐에 100개 이상 작업이 쌓이면 알림
            'failed_jobs': 10,      # 실패한 작업이 10개 이상이면 알림
            'no_workers': True,     # 워커가 없으면 알림
            'memory_usage': 80      # 메모리 사용률 80% 이상이면 알림
        }
    
    def check_queue_health(self):
        """큐 건강 상태 체크"""
        alerts = []
        
        # 워커 상태 체크
        workers = Worker.all(connection=self.connection)
        if not workers and self.alert_thresholds['no_workers']:
            alerts.append("No RQ workers are running!")
        
        # 각 큐별 상태 체크
        for queue_name in ['default', 'high', 'low']:
            from django_rq import get_queue
            queue = get_queue(queue_name)
            
            # 큐 크기 체크
            queue_size = len(queue)
            if queue_size >= self.alert_thresholds['queue_size']:
                alerts.append(f"Queue '{queue_name}' has {queue_size} pending jobs")
            
            # 실패한 작업 체크
            from rq.registry import FailedJobRegistry
            failed_registry = FailedJobRegistry(queue_name, connection=self.connection)
            failed_count = len(failed_registry)
            if failed_count >= self.alert_thresholds['failed_jobs']:
                alerts.append(f"Queue '{queue_name}' has {failed_count} failed jobs")
        
        return alerts
    
    def send_alert(self, message: str):
        """알림 발송"""
        if not settings.ALERT_EMAIL_RECIPIENTS:
            return
        
        try:
            msg = MimeText(f"""
RQ 모니터링 알림

시간: {time.strftime('%Y-%m-%d %H:%M:%S')}
메시지: {message}

시스템을 확인해주세요.
            """)
            
            msg['Subject'] = f'[RQ Alert] {message}'
            msg['From'] = settings.DEFAULT_FROM_EMAIL
            msg['To'] = ', '.join(settings.ALERT_EMAIL_RECIPIENTS)
            
            with smtplib.SMTP(settings.EMAIL_HOST, settings.EMAIL_PORT) as server:
                if settings.EMAIL_USE_TLS:
                    server.starttls()
                server.login(settings.EMAIL_HOST_USER, settings.EMAIL_HOST_PASSWORD)
                server.send_message(msg)
                
        except Exception as e:
            print(f"Failed to send alert: {e}")

# 모니터링 실행 스크립트
def run_monitoring():
    monitor = RQMonitor()
    
    while True:
        try:
            alerts = monitor.check_queue_health()
            
            for alert in alerts:
                print(f"ALERT: {alert}")
                monitor.send_alert(alert)
            
            time.sleep(60)  # 1분마다 체크
            
        except KeyboardInterrupt:
            print("Monitoring stopped")
            break
        except Exception as e:
            print(f"Monitoring error: {e}")
            time.sleep(60)

if __name__ == "__main__":
    run_monitoring()

프로덕션 설정

# settings/production.py
from .base import *
import os

DEBUG = False

# RQ 프로덕션 설정
RQ_QUEUES = {
    'default': {
        'HOST': os.environ.get('REDIS_HOST', 'localhost'),
        'PORT': int(os.environ.get('REDIS_PORT', 6379)),
        'DB': 0,
        'PASSWORD': os.environ.get('REDIS_PASSWORD'),
        'DEFAULT_TIMEOUT': 360,
        'CONNECTION_CLASS': 'redis.connection.Connection',
        'CONNECTION_KWARGS': {
            'health_check_interval': 30,
            'socket_keepalive': True,
            'socket_keepalive_options': {},
        },
    },
    'high': {
        'HOST': os.environ.get('REDIS_HOST', 'localhost'),
        'PORT': int(os.environ.get('REDIS_PORT', 6379)),
        'DB': 1,
        'PASSWORD': os.environ.get('REDIS_PASSWORD'),
        'DEFAULT_TIMEOUT': 180,
        'CONNECTION_CLASS': 'redis.connection.Connection',
    },
    'low': {
        'HOST': os.environ.get('REDIS_HOST', 'localhost'),
        'PORT': int(os.environ.get('REDIS_PORT', 6379)),
        'DB': 2,
        'PASSWORD': os.environ.get('REDIS_PASSWORD'),
        'DEFAULT_TIMEOUT': 600,
        'CONNECTION_CLASS': 'redis.connection.Connection',
    }
}

# 알림 설정
ALERT_EMAIL_RECIPIENTS = os.environ.get('ALERT_EMAIL_RECIPIENTS', '').split(',')

# 로깅 설정
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '[{levelname}] {asctime} {name} {process:d} {thread:d} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'file': {
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': '/var/log/django/rq.log',
            'maxBytes': 10485760,  # 10MB
            'backupCount': 5,
            'formatter': 'verbose',
        },
        'console': {
            'level': 'INFO',
            'class': 'logging.StreamHandler',
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'rq.worker': {
            'handlers': ['file', 'console'],
            'level': 'INFO',
            'propagate': False,
        },
        'tasks': {
            'handlers': ['file', 'console'],
            'level': 'INFO',
            'propagate': False,
        },
    },
}

🎯 마무리

Django Ninja와 Django RQ를 함께 사용하면 강력하고 확장 가능한 웹 애플리케이션을 구축할 수 있습니다. 이 가이드에서 다룬 내용을 정리하면:

✅ 주요 구현 사항

  • 완전한 통합: Django Ninja API와 RQ 백그라운드 작업의 seamless 연동
  • 다양한 작업 유형: 이메일, 이미지 처리, 데이터 분석, API 호출 등
  • 우선순위 큐: high, default, low 큐를 통한 작업 우선순위 관리
  • 실시간 모니터링: 상태 조회, 통계, 건강 체크 API
  • 오류 처리: 실패 작업 재시도, 예외 처리, 로깅

🚀 성능 최적화

  • 메모리 관리: 메모리 사용량 모니터링과 가비지 컬렉션
  • 배치 처리: 대량 작업의 효율적 처리
  • 연결 풀링: Redis 연결 최적화
  • 워커 스케일링: Docker Compose를 통한 수평 확장

🔧 운영 고려사항

  • 컨테이너화: Docker를 통한 일관된 배포
  • 모니터링: 자동화된 건강 체크와 알림
  • 확장성: 워커 수 조정과 큐 분리
  • 안정성: 재시도 로직과 실패 처리

📈 다음 단계

  • 고급 스케줄링: 주기적 작업과 cron 스타일 스케줄링
  • 작업 체이닝: 연관된 작업들의 파이프라인 구성
  • 메트릭스 수집: Prometheus/Grafana 연동
  • CI/CD: 자동화된 배포 파이프라인

Django RQ는 간단하면서도 강력한 백그라운드 작업 솔루션입니다. 이 가이드를 참고하여 여러분의 프로젝트에 맞는 최적의 구성을 만들어보세요!

💡 : 프로덕션 환경에서는 Redis 클러스터링과 센티널을 고려하여 고가용성을 확보하는 것이 좋습니다.

이 글을 공유해보세요!