← 블로그 홈

Django Ninja로 AWS SES 첨부파일 메일 발송 API 구현하기

1. 서론

1. 서론

1.1 왜 AWS SES를 사용하는가?

이메일 발송은 현대 웹 애플리케이션에서 필수적인 기능입니다. 회원가입 인증, 비밀번호 재설정, 알림, 마케팅 메일 등 다양한 용도로 사용되죠. 특히 첨부파일을 포함한 이메일 발송은 청구서, 리포트, 계약서 등을 전송할 때 자주 필요합니다.

AWS SES(Simple Email Service)는 다음과 같은 장점을 제공합니다:

  • 비용 효율성: 월 62,000건까지 무료, 이후 $0.10/1,000건
  • 높은 전송률: 초당 수백 건의 이메일 발송 가능
  • 뛰어난 전달률: AWS의 평판 관리 시스템으로 스팸 처리 최소화
  • 확장성: 트래픽 증가에 따라 자동으로 확장
  • 모니터링: CloudWatch를 통한 실시간 모니터링
  • 유연성: SMTP, API 두 가지 방식 모두 지원

1.2 Django Ninja의 장점

Django Ninja는 FastAPI에서 영감을 받아 만들어진 Django용 API 프레임워크로, 다음과 같은 특징을 가집니다:

  • 빠른 성능: Pydantic 기반의 빠른 데이터 검증
  • 자동 문서화: OpenAPI(Swagger) 자동 생성
  • 타입 힌팅: Python 3.6+ 타입 힌팅 완벽 지원
  • Django 통합: Django ORM, 인증 시스템 등과 완벽한 통합
  • 간결한 코드: FastAPI 스타일의 직관적인 문법

1.3 이 글에서 다룰 내용

이 포스트에서는 다음 내용을 실습과 함께 다룹니다:

  1. AWS SES 설정 및 인증
  2. Django 프로젝트 구조 설계
  3. 첨부파일 포함 이메일 발송 서비스 구현
  4. Django Ninja API 엔드포인트 작성
  5. 비동기 처리 및 성능 최적화
  6. 에러 핸들링 및 재시도 로직
  7. 테스트 코드 작성
  8. 프로덕션 배포 가이드

완성된 API는 다음 기능을 제공합니다:

  • 단일/다중 수신자에게 이메일 발송
  • 다양한 형식의 첨부파일 지원 (PDF, 이미지, 문서 등)
  • HTML/텍스트 이메일 모두 지원
  • 템플릿 기반 이메일 작성
  • 발송 상태 추적 및 로깅
  • 재시도 로직 및 에러 핸들링

2. AWS SES 설정

2.1 AWS SES 개요

AWS SES는 두 가지 발송 방식을 제공합니다:

  1. SMTP 인터페이스: 기존 이메일 클라이언트와 호환
  2. API 호출: boto3를 통한 프로그래밍 방식 (이 글에서 사용)

SES는 두 가지 환경으로 구분됩니다:

  • Sandbox 환경:
    • 일일 200통 제한
    • 인증된 이메일 주소로만 발송 가능
    • 테스트 및 개발용
  • Production 환경:
    • AWS 승인 후 사용 가능
    • 일일 50,000통 이상 발송 가능
    • 모든 이메일 주소로 발송 가능

2.2 AWS 계정 및 IAM 설정

먼저 AWS 계정이 필요합니다. 계정이 있다면 IAM 사용자를 생성하여 최소 권한 원칙을 적용합니다.

1) IAM 사용자 생성

AWS Console → IAM → Users → Add user

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ses:SendEmail",
        "ses:SendRawEmail",
        "ses:SendTemplatedEmail",
        "ses:GetSendQuota",
        "ses:GetSendStatistics"
      ],
      "Resource": "*"
    }
  ]
}

이 정책은 SES 이메일 발송에 필요한 최소한의 권한만 부여합니다.

2) Access Key 발급

IAM 사용자 생성 후 Security credentials 탭에서 Access Key를 발급받습니다:

Access Key ID: AKIAIOSFODNN7EXAMPLE
Secret Access Key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

⚠️ 보안 주의사항: Access Key는 절대 코드에 하드코딩하지 마세요. 환경 변수나 AWS Secrets Manager를 사용하세요.

2.3 SES 이메일/도메인 인증

SES에서 이메일을 발송하려면 발신자 주소를 인증해야 합니다.

방법 1: 개별 이메일 주소 인증

AWS Console → Amazon SES → Verified identities → Create identity

  1. Identity type: Email address 선택
  2. 이메일 주소 입력 (예: noreply@example.com)
  3. Create identity 클릭
  4. 받은 인증 메일에서 링크 클릭

방법 2: 도메인 인증 (권장)

도메인 전체를 인증하면 해당 도메인의 모든 이메일 주소를 사용할 수 있습니다.

  1. Identity type: Domain 선택
  2. 도메인 입력 (예: example.com)
  3. DNS 레코드 추가:
# Route53 또는 DNS 제공자에 추가할 레코드
Type: TXT
Name: _amazonses.example.com
Value: [AWS에서 제공하는 토큰]

# DKIM 레코드 (3개)
Type: CNAME
Name: [token1]._domainkey.example.com
Value: [token1].dkim.amazonses.com

Type: CNAME
Name: [token2]._domainkey.example.com
Value: [token2].dkim.amazonses.com

Type: CNAME
Name: [token3]._domainkey.example.com
Value: [token3].dkim.amazonses.com

# SPF 레코드 (선택사항, 권장)
Type: TXT
Name: example.com
Value: "v=spf1 include:amazonses.com ~all"

# DMARC 레코드 (선택사항, 권장)
Type: TXT
Name: _dmarc.example.com
Value: "v=DMARC1; p=quarantine; rua=mailto:admin@example.com"

DNS 전파는 최대 72시간이 걸릴 수 있지만 보통 몇 분 내에 완료됩니다.

2.4 Sandbox 해제 (프로덕션 사용 시)

프로덕션에서 사용하려면 AWS에 Sandbox 해제를 요청해야 합니다.

AWS Console → Amazon SES → Account dashboard → Request production access

요청 시 포함할 내용:

  • 이메일 발송 목적
  • 수신자가 구독을 해제하는 방법
  • 바운스 및 컴플레인 처리 방법
  • 예상 발송량

승인은 보통 24시간 내에 완료됩니다.

2.5 리전 선택

SES는 일부 리전에서만 사용 가능합니다:

  • 미국: us-east-1 (버지니아), us-west-2 (오레곤)
  • 유럽: eu-west-1 (아일랜드), eu-central-1 (프랑크푸르트)
  • 아시아: ap-south-1 (뭄바이), ap-southeast-1 (싱가포르), ap-southeast-2 (시드니), ap-northeast-1 (도쿄)

한국에서 사용 시 ap-northeast-1 (도쿄) 리전을 권장합니다.

3. Django 프로젝트 설정

3.1 프로젝트 구조

완성될 프로젝트 구조는 다음과 같습니다:

email_service/
├── manage.py
├── requirements.txt
├── .env
├── config/
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── asgi.py
├── apps/
│   └── emails/
│       ├── __init__.py
│       ├── models.py
│       ├── schemas.py
│       ├── services/
│       │   ├── __init__.py
│       │   ├── ses_client.py
│       │   └── email_service.py
│       ├── api.py
│       ├── tasks.py
│       └── tests/
│           ├── __init__.py
│           ├── test_services.py
│           └── test_api.py
└── templates/
    └── emails/
        ├── base.html
        └── notification.html

3.2 환경 설정

1) 가상환경 생성 및 패키지 설치

# 가상환경 생성
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

# Django 프로젝트 생성
pip install django
django-admin startproject config .
python manage.py startapp apps/emails

# 필요한 패키지 설치
pip install django-ninja boto3 python-dotenv celery redis pydantic pillow

2) requirements.txt

Django==5.0.0
django-ninja==1.1.0
boto3==1.34.0
python-dotenv==1.0.0
celery==5.3.4
redis==5.0.1
pydantic==2.5.0
pydantic-settings==2.1.0
Pillow==10.1.0
python-magic==0.4.27

# Development
pytest==7.4.3
pytest-django==4.7.0
pytest-cov==4.1.0
moto==4.2.9  # AWS 서비스 mocking
faker==21.0.0

3) .env 파일

# Django
DEBUG=True
SECRET_KEY=your-secret-key-here
ALLOWED_HOSTS=localhost,127.0.0.1

# AWS SES
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
AWS_REGION=ap-northeast-1
AWS_SES_FROM_EMAIL=noreply@example.com

# Email 설정
EMAIL_MAX_ATTACHMENT_SIZE=10485760  # 10MB
EMAIL_ALLOWED_EXTENSIONS=pdf,jpg,jpeg,png,gif,doc,docx,xls,xlsx,txt

# Celery (비동기 작업용)
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0

# 로깅
LOG_LEVEL=INFO

⚠️ .env 파일은 .gitignore에 추가하세요!

4) settings.py 수정

# config/settings.py
from pathlib import Path
from dotenv import load_dotenv
import os

load_dotenv()

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.getenv('SECRET_KEY')
DEBUG = os.getenv('DEBUG', 'False') == 'True'
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split(',')

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    
    # Third party
    'ninja',
    
    # Local apps
    'apps.emails',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'config.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

# AWS SES 설정
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_REGION = os.getenv('AWS_REGION', 'ap-northeast-1')
AWS_SES_FROM_EMAIL = os.getenv('AWS_SES_FROM_EMAIL')

# Email 설정
EMAIL_MAX_ATTACHMENT_SIZE = int(os.getenv('EMAIL_MAX_ATTACHMENT_SIZE', 10485760))
EMAIL_ALLOWED_EXTENSIONS = os.getenv('EMAIL_ALLOWED_EXTENSIONS', '').split(',')

# Celery 설정
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'Asia/Seoul'

# 로깅 설정
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{levelname} {asctime} {module} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'formatter': 'verbose',
        },
        'file': {
            'class': 'logging.FileHandler',
            'filename': BASE_DIR / 'logs' / 'email_service.log',
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'apps.emails': {
            'handlers': ['console', 'file'],
            'level': os.getenv('LOG_LEVEL', 'INFO'),
            'propagate': False,
        },
    },
}

# 보안 설정 (프로덕션)
if not DEBUG:
    SECURE_SSL_REDIRECT = True
    SESSION_COOKIE_SECURE = True
    CSRF_COOKIE_SECURE = True
    SECURE_BROWSER_XSS_FILTER = True
    SECURE_CONTENT_TYPE_NOSNIFF = True
    X_FRAME_OPTIONS = 'DENY'

5) Celery 설정

# config/celery.py
import os
from celery import Celery

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

app = Celery('email_service')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

@app.task(bind=True)
def debug_task(self):
    print(f'Request: {self.request!r}')
# config/__init__.py
from .celery import app as celery_app

__all__ = ('celery_app',)

3.3 Django Ninja 라우터 설정

# config/urls.py
from django.contrib import admin
from django.urls import path
from ninja import NinjaAPI
from apps.emails.api import router as email_router

api = NinjaAPI(
    title="Email Service API",
    version="1.0.0",
    description="AWS SES 기반 이메일 발송 서비스",
    docs_url="/docs",
)

api.add_router("/emails/", email_router)

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', api.urls),
]

이제 기본 설정이 완료되었습니다. 다음 섹션에서는 핵심 이메일 서비스를 구현하겠습니다.

4. 이메일 서비스 구현

4.1 데이터 모델 정의

먼저 이메일 발송 이력을 저장할 모델을 정의합니다.

# apps/emails/models.py
from django.db import models
from django.contrib.postgres.fields import ArrayField
import uuid

class EmailLog(models.Model):
    """이메일 발송 로그"""
    
    class Status(models.TextChoices):
        PENDING = 'pending', '대기중'
        SENDING = 'sending', '발송중'
        SENT = 'sent', '발송완료'
        FAILED = 'failed', '실패'
        BOUNCED = 'bounced', '반송됨'
        COMPLAINED = 'complained', '스팸신고'
    
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    
    # 발신자/수신자 정보
    from_email = models.EmailField(verbose_name="발신자")
    to_emails = models.JSONField(verbose_name="수신자 목록")
    cc_emails = models.JSONField(default=list, blank=True, verbose_name="참조")
    bcc_emails = models.JSONField(default=list, blank=True, verbose_name="숨은참조")
    
    # 이메일 내용
    subject = models.CharField(max_length=255, verbose_name="제목")
    body_text = models.TextField(blank=True, verbose_name="텍스트 본문")
    body_html = models.TextField(blank=True, verbose_name="HTML 본문")
    
    # 첨부파일
    attachments = models.JSONField(default=list, blank=True, verbose_name="첨부파일")
    
    # 발송 상태
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.PENDING,
        verbose_name="상태"
    )
    message_id = models.CharField(max_length=255, blank=True, verbose_name="SES 메시지 ID")
    
    # 에러 정보
    error_message = models.TextField(blank=True, verbose_name="에러 메시지")
    retry_count = models.IntegerField(default=0, verbose_name="재시도 횟수")
    
    # 타임스탬프
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="생성일시")
    sent_at = models.DateTimeField(null=True, blank=True, verbose_name="발송일시")
    
    class Meta:
        db_table = 'email_logs'
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['-created_at']),
            models.Index(fields=['status']),
            models.Index(fields=['message_id']),
        ]
    
    def __str__(self):
        return f"{self.subject} - {self.status}"


class EmailTemplate(models.Model):
    """이메일 템플릿"""
    
    name = models.CharField(max_length=100, unique=True, verbose_name="템플릿명")
    subject = models.CharField(max_length=255, verbose_name="제목")
    body_html = models.TextField(verbose_name="HTML 본문")
    body_text = models.TextField(blank=True, verbose_name="텍스트 본문")
    
    # 템플릿 변수 설명 (JSON)
    variables = models.JSONField(
        default=dict,
        blank=True,
        help_text="템플릿에서 사용 가능한 변수들",
        verbose_name="변수"
    )
    
    is_active = models.BooleanField(default=True, verbose_name="활성화")
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="생성일시")
    updated_at = models.DateTimeField(auto_now=True, verbose_name="수정일시")
    
    class Meta:
        db_table = 'email_templates'
        ordering = ['name']
    
    def __str__(self):
        return self.name

마이그레이션을 생성하고 적용합니다:

python manage.py makemigrations
python manage.py migrate

4.2 SES 클라이언트 래퍼

boto3를 사용하여 SES와 통신하는 클라이언트 클래스를 만듭니다.

# apps/emails/services/ses_client.py
import boto3
from botocore.exceptions import ClientError, BotoCoreError
from django.conf import settings
import logging
from typing import Dict, Any

logger = logging.getLogger(__name__)


class SESClientError(Exception):
    """SES 클라이언트 에러"""
    pass


class SESClient:
    """AWS SES 클라이언트 래퍼"""
    
    def __init__(self):
        """SES 클라이언트 초기화"""
        try:
            self.client = boto3.client(
                'ses',
                aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
                aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
                region_name=settings.AWS_REGION
            )
            logger.info(f"SES 클라이언트 초기화 완료: {settings.AWS_REGION}")
        except Exception as e:
            logger.error(f"SES 클라이언트 초기화 실패: {str(e)}")
            raise SESClientError(f"SES 클라이언트 초기화 실패: {str(e)}")
    
    def send_raw_email(self, raw_message: bytes, source: str, destinations: list) -> Dict[str, Any]:
        """
        Raw 이메일 발송 (첨부파일 포함 가능)
        
        Args:
            raw_message: MIME 형식의 원본 메시지
            source: 발신자 이메일
            destinations: 수신자 이메일 리스트
            
        Returns:
            SES 응답 (MessageId 포함)
            
        Raises:
            SESClientError: 발송 실패 시
        """
        try:
            response = self.client.send_raw_email(
                Source=source,
                Destinations=destinations,
                RawMessage={'Data': raw_message}
            )
            
            logger.info(
                f"이메일 발송 성공: MessageId={response['MessageId']}, "
                f"Source={source}, Destinations={destinations}"
            )
            
            return response
            
        except ClientError as e:
            error_code = e.response['Error']['Code']
            error_message = e.response['Error']['Message']
            
            logger.error(
                f"SES ClientError: {error_code} - {error_message}, "
                f"Source={source}, Destinations={destinations}"
            )
            
            # 에러 코드별 처리
            if error_code == 'MessageRejected':
                raise SESClientError(f"메시지 거부됨: {error_message}")
            elif error_code == 'MailFromDomainNotVerified':
                raise SESClientError(f"발신자 도메인 미인증: {error_message}")
            elif error_code == 'ConfigurationSetDoesNotExist':
                raise SESClientError(f"설정 세트 없음: {error_message}")
            elif error_code == 'AccountSendingPausedException':
                raise SESClientError(f"계정 발송 일시중지: {error_message}")
            else:
                raise SESClientError(f"SES 에러 ({error_code}): {error_message}")
                
        except BotoCoreError as e:
            logger.error(f"BotoCore 에러: {str(e)}")
            raise SESClientError(f"AWS 연결 에러: {str(e)}")
            
        except Exception as e:
            logger.error(f"예상치 못한 에러: {str(e)}")
            raise SESClientError(f"이메일 발송 실패: {str(e)}")
    
    def get_send_quota(self) -> Dict[str, Any]:
        """
        SES 발송 쿼터 조회
        
        Returns:
            Max24HourSend: 24시간 최대 발송량
            MaxSendRate: 초당 최대 발송량
            SentLast24Hours: 최근 24시간 발송량
        """
        try:
            response = self.client.get_send_quota()
            logger.info(f"발송 쿼터 조회: {response}")
            return response
        except Exception as e:
            logger.error(f"쿼터 조회 실패: {str(e)}")
            raise SESClientError(f"쿼터 조회 실패: {str(e)}")
    
    def get_send_statistics(self) -> Dict[str, Any]:
        """
        SES 발송 통계 조회
        
        Returns:
            최근 2주간의 발송 통계
        """
        try:
            response = self.client.get_send_statistics()
            logger.info(f"발송 통계 조회 완료")
            return response
        except Exception as e:
            logger.error(f"통계 조회 실패: {str(e)}")
            raise SESClientError(f"통계 조회 실패: {str(e)}")
    
    def verify_email_identity(self, email: str) -> Dict[str, Any]:
        """
        이메일 주소 인증 요청
        
        Args:
            email: 인증할 이메일 주소
            
        Returns:
            SES 응답
        """
        try:
            response = self.client.verify_email_identity(EmailAddress=email)
            logger.info(f"이메일 인증 요청: {email}")
            return response
        except Exception as e:
            logger.error(f"이메일 인증 요청 실패: {str(e)}")
            raise SESClientError(f"이메일 인증 요청 실패: {str(e)}")

4.3 MIME 메시지 생성 및 첨부파일 처리

이메일 본문과 첨부파일을 MIME 형식으로 구성하는 서비스를 만듭니다.

# apps/emails/services/email_service.py
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from email.mime.image import MIMEImage
from email.mime.audio import MIMEAudio
from email.mime.base import MIMEBase
from email import encoders
from typing import List, Dict, Optional, Tuple
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.template.loader import render_to_string
import logging
import mimetypes
import os

from .ses_client import SESClient, SESClientError
from apps.emails.models import EmailLog

logger = logging.getLogger(__name__)


class EmailAttachment:
    """이메일 첨부파일 클래스"""
    
    def __init__(
        self,
        filename: str,
        content: bytes,
        content_type: Optional[str] = None
    ):
        self.filename = filename
        self.content = content
        self.content_type = content_type or self._guess_content_type()
    
    def _guess_content_type(self) -> str:
        """파일명으로부터 content type 추측"""
        content_type, _ = mimetypes.guess_type(self.filename)
        return content_type or 'application/octet-stream'
    
    def validate(self) -> Tuple[bool, str]:
        """첨부파일 유효성 검증"""
        # 파일 크기 검증
        if len(self.content) > settings.EMAIL_MAX_ATTACHMENT_SIZE:
            max_mb = settings.EMAIL_MAX_ATTACHMENT_SIZE / (1024 * 1024)
            return False, f"파일 크기가 {max_mb}MB를 초과합니다"
        
        # 확장자 검증
        ext = os.path.splitext(self.filename)[1].lower().lstrip('.')
        if ext not in settings.EMAIL_ALLOWED_EXTENSIONS:
            return False, f"허용되지 않는 파일 형식입니다: {ext}"
        
        return True, "OK"


class EmailService:
    """이메일 발송 서비스"""
    
    def __init__(self):
        self.ses_client = SESClient()
    
    def send_email(
        self,
        to_emails: List[str],
        subject: str,
        body_html: Optional[str] = None,
        body_text: Optional[str] = None,
        from_email: Optional[str] = None,
        cc_emails: Optional[List[str]] = None,
        bcc_emails: Optional[List[str]] = None,
        attachments: Optional[List[EmailAttachment]] = None,
        reply_to: Optional[List[str]] = None,
    ) -> EmailLog:
        """
        이메일 발송
        
        Args:
            to_emails: 수신자 리스트
            subject: 제목
            body_html: HTML 본문
            body_text: 텍스트 본문
            from_email: 발신자 (기본값: settings.AWS_SES_FROM_EMAIL)
            cc_emails: 참조
            bcc_emails: 숨은 참조
            attachments: 첨부파일 리스트
            reply_to: 회신 주소
            
        Returns:
            EmailLog 인스턴스
        """
        from_email = from_email or settings.AWS_SES_FROM_EMAIL
        cc_emails = cc_emails or []
        bcc_emails = bcc_emails or []
        attachments = attachments or []
        
        # EmailLog 생성
        email_log = EmailLog.objects.create(
            from_email=from_email,
            to_emails=to_emails,
            cc_emails=cc_emails,
            bcc_emails=bcc_emails,
            subject=subject,
            body_html=body_html or '',
            body_text=body_text or '',
            attachments=[
                {
                    'filename': att.filename,
                    'content_type': att.content_type,
                    'size': len(att.content)
                }
                for att in attachments
            ],
            status=EmailLog.Status.PENDING
        )
        
        try:
            # 첨부파일 유효성 검증
            for attachment in attachments:
                is_valid, error_msg = attachment.validate()
                if not is_valid:
                    raise ValueError(f"{attachment.filename}: {error_msg}")
            
            # MIME 메시지 생성
            mime_message = self._create_mime_message(
                from_email=from_email,
                to_emails=to_emails,
                cc_emails=cc_emails,
                subject=subject,
                body_html=body_html,
                body_text=body_text,
                attachments=attachments,
                reply_to=reply_to
            )
            
            # 모든 수신자 리스트
            all_destinations = to_emails + cc_emails + bcc_emails
            
            # 발송 상태 업데이트
            email_log.status = EmailLog.Status.SENDING
            email_log.save(update_fields=['status'])
            
            # SES로 발송
            response = self.ses_client.send_raw_email(
                raw_message=mime_message.as_bytes(),
                source=from_email,
                destinations=all_destinations
            )
            
            # 성공 처리
            email_log.status = EmailLog.Status.SENT
            email_log.message_id = response['MessageId']
            email_log.sent_at = timezone.now()
            email_log.save(update_fields=['status', 'message_id', 'sent_at'])
            
            logger.info(f"이메일 발송 성공: {email_log.id}")
            return email_log
            
        except (SESClientError, ValueError) as e:
            # 에러 처리
            email_log.status = EmailLog.Status.FAILED
            email_log.error_message = str(e)
            email_log.save(update_fields=['status', 'error_message'])
            
            logger.error(f"이메일 발송 실패: {email_log.id} - {str(e)}")
            raise
        
        except Exception as e:
            # 예상치 못한 에러
            email_log.status = EmailLog.Status.FAILED
            email_log.error_message = f"예상치 못한 에러: {str(e)}"
            email_log.save(update_fields=['status', 'error_message'])
            
            logger.exception(f"이메일 발송 중 예외 발생: {email_log.id}")
            raise
    
    def _create_mime_message(
        self,
        from_email: str,
        to_emails: List[str],
        cc_emails: List[str],
        subject: str,
        body_html: Optional[str],
        body_text: Optional[str],
        attachments: List[EmailAttachment],
        reply_to: Optional[List[str]]
    ) -> MIMEMultipart:
        """MIME 메시지 생성"""
        
        # Mixed 타입 메시지 (첨부파일 포함 가능)
        msg = MIMEMultipart('mixed')
        msg['Subject'] = subject
        msg['From'] = from_email
        msg['To'] = ', '.join(to_emails)
        
        if cc_emails:
            msg['Cc'] = ', '.join(cc_emails)
        
        if reply_to:
            msg['Reply-To'] = ', '.join(reply_to)
        
        # Alternative 파트 생성 (HTML과 텍스트)
        msg_alternative = MIMEMultipart('alternative')
        msg.attach(msg_alternative)
        
        # 텍스트 본문
        if body_text:
            part_text = MIMEText(body_text, 'plain', 'utf-8')
            msg_alternative.attach(part_text)
        
        # HTML 본문
        if body_html:
            part_html = MIMEText(body_html, 'html', 'utf-8')
            msg_alternative.attach(part_html)
        
        # 첨부파일 추가
        for attachment in attachments:
            msg.attach(self._create_attachment_part(attachment))
        
        return msg
    
    def _create_attachment_part(self, attachment: EmailAttachment) -> MIMEBase:
        """첨부파일 MIME 파트 생성"""
        
        maintype, subtype = attachment.content_type.split('/', 1)
        
        if maintype == 'text':
            part = MIMEText(
                attachment.content.decode('utf-8'),
                _subtype=subtype
            )
        elif maintype == 'image':
            part = MIMEImage(attachment.content, _subtype=subtype)
        elif maintype == 'audio':
            part = MIMEAudio(attachment.content, _subtype=subtype)
        elif maintype == 'application':
            part = MIMEApplication(attachment.content, _subtype=subtype)
        else:
            part = MIMEBase(maintype, subtype)
            part.set_payload(attachment.content)
            encoders.encode_base64(part)
        
        part.add_header(
            'Content-Disposition',
            'attachment',
            filename=attachment.filename
        )
        
        return part
    
    def send_templated_email(
        self,
        to_emails: List[str],
        template_name: str,
        context: Dict,
        from_email: Optional[str] = None,
        attachments: Optional[List[EmailAttachment]] = None
    ) -> EmailLog:
        """
        템플릿 기반 이메일 발송
        
        Args:
            to_emails: 수신자 리스트
            template_name: 템플릿 이름
            context: 템플릿 컨텍스트
            from_email: 발신자
            attachments: 첨부파일
            
        Returns:
            EmailLog 인스턴스
        """
        # Django 템플릿 렌더링
        html_template = f'emails/{template_name}.html'
        text_template = f'emails/{template_name}.txt'
        
        try:
            body_html = render_to_string(html_template, context)
        except Exception:
            body_html = None
        
        try:
            body_text = render_to_string(text_template, context)
        except Exception:
            body_text = None
        
        # 제목은 컨텍스트에서 가져오기
        subject = context.get('subject', 'No Subject')
        
        return self.send_email(
            to_emails=to_emails,
            subject=subject,
            body_html=body_html,
            body_text=body_text,
            from_email=from_email,
            attachments=attachments
        )


# Django timezone import 추가
from django.utils import timezone

이제 핵심 서비스 로직이 완성되었습니다. 다음 섹션에서는 Django Ninja API를 구현하겠습니다.

5. Django Ninja API 구현

5.1 Pydantic 스키마 정의

API 요청/응답에 사용할 스키마를 정의합니다.

# apps/emails/schemas.py
from pydantic import BaseModel, EmailStr, Field, validator
from typing import List, Optional
from datetime import datetime
from enum import Enum


class EmailStatus(str, Enum):
    """이메일 상태"""
    PENDING = 'pending'
    SENDING = 'sending'
    SENT = 'sent'
    FAILED = 'failed'
    BOUNCED = 'bounced'
    COMPLAINED = 'complained'


class AttachmentInfo(BaseModel):
    """첨부파일 정보"""
    filename: str
    content_type: str
    size: int


class SendEmailRequest(BaseModel):
    """이메일 발송 요청"""
    to_emails: List[EmailStr] = Field(..., min_items=1, max_items=50)
    subject: str = Field(..., min_length=1, max_length=255)
    body_html: Optional[str] = None
    body_text: Optional[str] = None
    cc_emails: Optional[List[EmailStr]] = Field(default=None, max_items=50)
    bcc_emails: Optional[List[EmailStr]] = Field(default=None, max_items=50)
    reply_to: Optional[List[EmailStr]] = Field(default=None, max_items=5)
    
    @validator('body_html', 'body_text')
    def validate_body(cls, v, values):
        """HTML 또는 텍스트 본문 중 하나는 필수"""
        if 'body_html' in values and not values['body_html'] and not v:
            raise ValueError('body_html 또는 body_text 중 하나는 필수입니다')
        return v
    
    class Config:
        schema_extra = {
            "example": {
                "to_emails": ["user@example.com"],
                "subject": "테스트 이메일",
                "body_html": "<h1>안녕하세요</h1><p>테스트 메일입니다.</p>",
                "body_text": "안녕하세요\n테스트 메일입니다.",
                "cc_emails": ["cc@example.com"],
                "reply_to": ["reply@example.com"]
            }
        }


class SendTemplatedEmailRequest(BaseModel):
    """템플릿 이메일 발송 요청"""
    to_emails: List[EmailStr] = Field(..., min_items=1, max_items=50)
    template_name: str = Field(..., min_length=1, max_length=100)
    context: dict = Field(default_factory=dict)
    
    class Config:
        schema_extra = {
            "example": {
                "to_emails": ["user@example.com"],
                "template_name": "notification",
                "context": {
                    "subject": "알림",
                    "username": "홍길동",
                    "message": "새로운 알림이 있습니다."
                }
            }
        }


class EmailResponse(BaseModel):
    """이메일 발송 응답"""
    id: str
    status: EmailStatus
    message_id: Optional[str] = None
    created_at: datetime
    sent_at: Optional[datetime] = None
    
    class Config:
        from_attributes = True


class EmailLogResponse(BaseModel):
    """이메일 로그 상세 응답"""
    id: str
    from_email: str
    to_emails: List[str]
    cc_emails: List[str]
    bcc_emails: List[str]
    subject: str
    status: EmailStatus
    message_id: Optional[str] = None
    attachments: List[AttachmentInfo]
    error_message: Optional[str] = None
    retry_count: int
    created_at: datetime
    sent_at: Optional[datetime] = None
    
    class Config:
        from_attributes = True


class SendQuotaResponse(BaseModel):
    """발송 쿼터 응답"""
    max_24_hour_send: float
    max_send_rate: float
    sent_last_24_hours: float


class ErrorResponse(BaseModel):
    """에러 응답"""
    error: str
    detail: Optional[str] = None

5.2 API 라우터 구현

# apps/emails/api.py
from ninja import Router, File, UploadedFile
from ninja.errors import HttpError
from typing import List, Optional
from django.shortcuts import get_object_or_404
import logging

from .schemas import (
    SendEmailRequest,
    SendTemplatedEmailRequest,
    EmailResponse,
    EmailLogResponse,
    SendQuotaResponse,
    ErrorResponse
)
from .services.email_service import EmailService, EmailAttachment
from .services.ses_client import SESClientError
from .models import EmailLog

router = Router(tags=["emails"])
logger = logging.getLogger(__name__)


@router.post(
    "/send",
    response={200: EmailResponse, 400: ErrorResponse, 500: ErrorResponse},
    summary="이메일 발송",
    description="첨부파일 없이 이메일을 발송합니다."
)
def send_email(request, payload: SendEmailRequest):
    """
    이메일 발송 API
    
    - **to_emails**: 수신자 이메일 목록 (최대 50개)
    - **subject**: 이메일 제목
    - **body_html**: HTML 본문 (선택)
    - **body_text**: 텍스트 본문 (선택)
    - **cc_emails**: 참조 이메일 목록 (선택)
    - **bcc_emails**: 숨은 참조 이메일 목록 (선택)
    - **reply_to**: 회신 주소 (선택)
    
    HTML 또는 텍스트 본문 중 하나는 필수입니다.
    """
    try:
        email_service = EmailService()
        
        email_log = email_service.send_email(
            to_emails=payload.to_emails,
            subject=payload.subject,
            body_html=payload.body_html,
            body_text=payload.body_text,
            cc_emails=payload.cc_emails,
            bcc_emails=payload.bcc_emails,
            reply_to=payload.reply_to
        )
        
        return EmailResponse(
            id=str(email_log.id),
            status=email_log.status,
            message_id=email_log.message_id,
            created_at=email_log.created_at,
            sent_at=email_log.sent_at
        )
        
    except ValueError as e:
        raise HttpError(400, str(e))
    except SESClientError as e:
        raise HttpError(500, str(e))
    except Exception as e:
        logger.exception("이메일 발송 중 예외 발생")
        raise HttpError(500, "이메일 발송에 실패했습니다")


@router.post(
    "/send-with-attachments",
    response={200: EmailResponse, 400: ErrorResponse, 500: ErrorResponse},
    summary="첨부파일 포함 이메일 발송",
    description="첨부파일을 포함한 이메일을 발송합니다."
)
def send_email_with_attachments(
    request,
    to_emails: str,
    subject: str,
    body_html: Optional[str] = None,
    body_text: Optional[str] = None,
    cc_emails: Optional[str] = None,
    bcc_emails: Optional[str] = None,
    reply_to: Optional[str] = None,
    attachments: List[UploadedFile] = File(None)
):
    """
    첨부파일 포함 이메일 발송 API
    
    Form-data 형식으로 요청해야 합니다.
    
    - **to_emails**: 쉼표로 구분된 수신자 이메일
    - **subject**: 이메일 제목
    - **body_html**: HTML 본문 (선택)
    - **body_text**: 텍스트 본문 (선택)
    - **cc_emails**: 쉼표로 구분된 참조 이메일 (선택)
    - **bcc_emails**: 쉼표로 구분된 숨은 참조 이메일 (선택)
    - **reply_to**: 쉼표로 구분된 회신 주소 (선택)
    - **attachments**: 첨부파일 (복수 가능)
    """
    try:
        # 쉼표로 구분된 이메일 파싱
        to_list = [email.strip() for email in to_emails.split(',')]
        cc_list = [email.strip() for email in cc_emails.split(',')] if cc_emails else []
        bcc_list = [email.strip() for email in bcc_emails.split(',')] if bcc_emails else []
        reply_to_list = [email.strip() for email in reply_to.split(',')] if reply_to else []
        
        # 첨부파일 처리
        attachment_objects = []
        if attachments:
            for uploaded_file in attachments:
                content = uploaded_file.read()
                attachment = EmailAttachment(
                    filename=uploaded_file.name,
                    content=content,
                    content_type=uploaded_file.content_type
                )
                attachment_objects.append(attachment)
        
        # 이메일 발송
        email_service = EmailService()
        email_log = email_service.send_email(
            to_emails=to_list,
            subject=subject,
            body_html=body_html,
            body_text=body_text,
            cc_emails=cc_list if cc_list else None,
            bcc_emails=bcc_list if bcc_list else None,
            reply_to=reply_to_list if reply_to_list else None,
            attachments=attachment_objects
        )
        
        return EmailResponse(
            id=str(email_log.id),
            status=email_log.status,
            message_id=email_log.message_id,
            created_at=email_log.created_at,
            sent_at=email_log.sent_at
        )
        
    except ValueError as e:
        raise HttpError(400, str(e))
    except SESClientError as e:
        raise HttpError(500, str(e))
    except Exception as e:
        logger.exception("첨부파일 이메일 발송 중 예외 발생")
        raise HttpError(500, "이메일 발송에 실패했습니다")


@router.post(
    "/send-templated",
    response={200: EmailResponse, 400: ErrorResponse, 500: ErrorResponse},
    summary="템플릿 이메일 발송",
    description="미리 정의된 템플릿으로 이메일을 발송합니다."
)
def send_templated_email(request, payload: SendTemplatedEmailRequest):
    """
    템플릿 기반 이메일 발송 API
    
    - **to_emails**: 수신자 이메일 목록
    - **template_name**: 템플릿 이름
    - **context**: 템플릿 변수
    """
    try:
        email_service = EmailService()
        
        email_log = email_service.send_templated_email(
            to_emails=payload.to_emails,
            template_name=payload.template_name,
            context=payload.context
        )
        
        return EmailResponse(
            id=str(email_log.id),
            status=email_log.status,
            message_id=email_log.message_id,
            created_at=email_log.created_at,
            sent_at=email_log.sent_at
        )
        
    except ValueError as e:
        raise HttpError(400, str(e))
    except SESClientError as e:
        raise HttpError(500, str(e))
    except Exception as e:
        logger.exception("템플릿 이메일 발송 중 예외 발생")
        raise HttpError(500, "이메일 발송에 실패했습니다")


@router.get(
    "/logs/{email_id}",
    response={200: EmailLogResponse, 404: ErrorResponse},
    summary="이메일 로그 조회",
    description="특정 이메일의 상세 정보를 조회합니다."
)
def get_email_log(request, email_id: str):
    """
    이메일 로그 조회 API
    
    - **email_id**: 이메일 ID (UUID)
    """
    email_log = get_object_or_404(EmailLog, id=email_id)
    
    return EmailLogResponse(
        id=str(email_log.id),
        from_email=email_log.from_email,
        to_emails=email_log.to_emails,
        cc_emails=email_log.cc_emails,
        bcc_emails=email_log.bcc_emails,
        subject=email_log.subject,
        status=email_log.status,
        message_id=email_log.message_id,
        attachments=[AttachmentInfo(**att) for att in email_log.attachments],
        error_message=email_log.error_message,
        retry_count=email_log.retry_count,
        created_at=email_log.created_at,
        sent_at=email_log.sent_at
    )


@router.get(
    "/logs",
    response=List[EmailLogResponse],
    summary="이메일 로그 목록",
    description="이메일 발송 로그 목록을 조회합니다."
)
def list_email_logs(
    request,
    status: Optional[str] = None,
    limit: int = 50,
    offset: int = 0
):
    """
    이메일 로그 목록 조회 API
    
    - **status**: 상태 필터 (pending, sending, sent, failed 등)
    - **limit**: 조회할 개수 (기본 50, 최대 100)
    - **offset**: 시작 위치
    """
    queryset = EmailLog.objects.all()
    
    if status:
        queryset = queryset.filter(status=status)
    
    limit = min(limit, 100)
    email_logs = queryset[offset:offset + limit]
    
    return [
        EmailLogResponse(
            id=str(log.id),
            from_email=log.from_email,
            to_emails=log.to_emails,
            cc_emails=log.cc_emails,
            bcc_emails=log.bcc_emails,
            subject=log.subject,
            status=log.status,
            message_id=log.message_id,
            attachments=[AttachmentInfo(**att) for att in log.attachments],
            error_message=log.error_message,
            retry_count=log.retry_count,
            created_at=log.created_at,
            sent_at=log.sent_at
        )
        for log in email_logs
    ]


@router.get(
    "/quota",
    response={200: SendQuotaResponse, 500: ErrorResponse},
    summary="SES 발송 쿼터 조회",
    description="AWS SES 발송 쿼터 정보를 조회합니다."
)
def get_send_quota(request):
    """
    SES 발송 쿼터 조회 API
    
    24시간 최대 발송량, 초당 발송량, 최근 24시간 발송량을 반환합니다.
    """
    try:
        email_service = EmailService()
        quota = email_service.ses_client.get_send_quota()
        
        return SendQuotaResponse(
            max_24_hour_send=quota['Max24HourSend'],
            max_send_rate=quota['MaxSendRate'],
            sent_last_24_hours=quota['SentLast24Hours']
        )
    except SESClientError as e:
        raise HttpError(500, str(e))

5.3 API 테스트

1) Swagger UI를 통한 테스트

서버를 실행하고 http://localhost:8000/api/docs에 접속하면 자동 생성된 API 문서를 확인할 수 있습니다.

python manage.py runserver

2) cURL을 통한 테스트

# 간단한 이메일 발송
curl -X POST "http://localhost:8000/api/emails/send" \
  -H "Content-Type: application/json" \
  -d '{
    "to_emails": ["test@example.com"],
    "subject": "테스트 이메일",
    "body_html": "<h1>안녕하세요</h1><p>테스트입니다.</p>",
    "body_text": "안녕하세요\n테스트입니다."
  }'

# 첨부파일 포함 이메일 발송
curl -X POST "http://localhost:8000/api/emails/send-with-attachments" \
  -F "to_emails=test@example.com" \
  -F "subject=첨부파일 테스트" \
  -F "body_html=<h1>첨부파일이 있습니다</h1>" \
  -F "attachments=@/path/to/file.pdf" \
  -F "attachments=@/path/to/image.jpg"

# 이메일 로그 조회
curl "http://localhost:8000/api/emails/logs/{email_id}"

# 발송 쿼터 조회
curl "http://localhost:8000/api/emails/quota"

3) Python requests를 통한 테스트

import requests

# 간단한 이메일 발송
response = requests.post(
    'http://localhost:8000/api/emails/send',
    json={
        'to_emails': ['test@example.com'],
        'subject': '테스트 이메일',
        'body_html': '<h1>안녕하세요</h1>',
        'body_text': '안녕하세요'
    }
)
print(response.json())

# 첨부파일 포함 이메일
files = [
    ('attachments', ('report.pdf', open('report.pdf', 'rb'), 'application/pdf')),
    ('attachments', ('chart.png', open('chart.png', 'rb'), 'image/png'))
]
data = {
    'to_emails': 'test@example.com',
    'subject': '리포트 발송',
    'body_html': '<h1>월간 리포트</h1>'
}
response = requests.post(
    'http://localhost:8000/api/emails/send-with-attachments',
    files=files,
    data=data
)
print(response.json())

이제 기본 API가 완성되었습니다. 다음 섹션에서는 비동기 처리와 고급 기능을 구현하겠습니다.

6. 고급 기능 및 최적화

6.1 Celery를 활용한 비동기 이메일 발송

대량의 이메일을 발송하거나 응답 시간이 중요한 API에서는 비동기 처리가 필수입니다.

# apps/emails/tasks.py
from celery import shared_task
from celery.utils.log import get_task_logger
from typing import List, Optional, Dict
import time

from .services.email_service import EmailService, EmailAttachment
from .models import EmailLog

logger = get_task_logger(__name__)


@shared_task(
    bind=True,
    max_retries=3,
    default_retry_delay=60,  # 60초 후 재시도
    autoretry_for=(Exception,),
    retry_backoff=True,  # 지수 백오프
    retry_jitter=True  # 재시도 시간에 랜덤성 추가
)
def send_email_task(
    self,
    to_emails: List[str],
    subject: str,
    body_html: Optional[str] = None,
    body_text: Optional[str] = None,
    from_email: Optional[str] = None,
    cc_emails: Optional[List[str]] = None,
    bcc_emails: Optional[List[str]] = None,
    attachment_data: Optional[List[Dict]] = None
) -> str:
    """
    비동기 이메일 발송 태스크
    
    Returns:
        EmailLog ID
    """
    try:
        logger.info(f"이메일 발송 태스크 시작: to={to_emails}, subject={subject}")
        
        # 첨부파일 복원
        attachments = []
        if attachment_data:
            for att_data in attachment_data:
                attachment = EmailAttachment(
                    filename=att_data['filename'],
                    content=att_data['content'].encode() if isinstance(att_data['content'], str) else att_data['content'],
                    content_type=att_data['content_type']
                )
                attachments.append(attachment)
        
        # 이메일 발송
        email_service = EmailService()
        email_log = email_service.send_email(
            to_emails=to_emails,
            subject=subject,
            body_html=body_html,
            body_text=body_text,
            from_email=from_email,
            cc_emails=cc_emails,
            bcc_emails=bcc_emails,
            attachments=attachments if attachments else None
        )
        
        logger.info(f"이메일 발송 완료: {email_log.id}")
        return str(email_log.id)
        
    except Exception as e:
        logger.error(f"이메일 발송 실패 (재시도 {self.request.retries}/{self.max_retries}): {str(e)}")
        
        # 최대 재시도 횟수 도달 시
        if self.request.retries >= self.max_retries:
            logger.error(f"최대 재시도 횟수 도달. 이메일 발송 포기")
        
        raise


@shared_task
def send_bulk_emails_task(email_data_list: List[Dict]) -> Dict:
    """
    대량 이메일 발송 태스크
    
    Args:
        email_data_list: 이메일 데이터 리스트
        
    Returns:
        발송 결과 통계
    """
    logger.info(f"대량 이메일 발송 시작: {len(email_data_list)}건")
    
    results = {
        'total': len(email_data_list),
        'success': 0,
        'failed': 0,
        'email_ids': []
    }
    
    email_service = EmailService()
    
    for email_data in email_data_list:
        try:
            email_log = email_service.send_email(
                to_emails=email_data['to_emails'],
                subject=email_data['subject'],
                body_html=email_data.get('body_html'),
                body_text=email_data.get('body_text'),
                from_email=email_data.get('from_email')
            )
            
            results['success'] += 1
            results['email_ids'].append(str(email_log.id))
            
            # SES rate limit 고려 (초당 발송량 제한)
            time.sleep(0.1)  # 초당 10건으로 제한
            
        except Exception as e:
            logger.error(f"이메일 발송 실패: {str(e)}")
            results['failed'] += 1
    
    logger.info(f"대량 이메일 발송 완료: 성공 {results['success']}, 실패 {results['failed']}")
    return results


@shared_task
def retry_failed_emails_task(hours: int = 24) -> Dict:
    """
    실패한 이메일 재발송 태스크
    
    Args:
        hours: 최근 몇 시간 이내의 실패 이메일을 재발송할지
        
    Returns:
        재발송 결과
    """
    from django.utils import timezone
    from datetime import timedelta
    
    cutoff_time = timezone.now() - timedelta(hours=hours)
    
    # 실패한 이메일 조회 (재시도 횟수 3회 미만)
    failed_emails = EmailLog.objects.filter(
        status=EmailLog.Status.FAILED,
        created_at__gte=cutoff_time,
        retry_count__lt=3
    )
    
    logger.info(f"재발송 대상 이메일: {failed_emails.count()}건")
    
    results = {
        'total': failed_emails.count(),
        'success': 0,
        'failed': 0
    }
    
    email_service = EmailService()
    
    for email_log in failed_emails:
        try:
            # 재발송
            new_log = email_service.send_email(
                to_emails=email_log.to_emails,
                subject=email_log.subject,
                body_html=email_log.body_html,
                body_text=email_log.body_text,
                from_email=email_log.from_email,
                cc_emails=email_log.cc_emails if email_log.cc_emails else None,
                bcc_emails=email_log.bcc_emails if email_log.bcc_emails else None
            )
            
            # 원본 로그 업데이트
            email_log.retry_count += 1
            email_log.save(update_fields=['retry_count'])
            
            results['success'] += 1
            logger.info(f"이메일 재발송 성공: {email_log.id} -> {new_log.id}")
            
            time.sleep(0.1)
            
        except Exception as e:
            email_log.retry_count += 1
            email_log.save(update_fields=['retry_count'])
            results['failed'] += 1
            logger.error(f"이메일 재발송 실패: {email_log.id} - {str(e)}")
    
    return results

비동기 API 엔드포인트 추가

# apps/emails/api.py에 추가

@router.post(
    "/send-async",
    response={202: dict, 400: ErrorResponse},
    summary="비동기 이메일 발송",
    description="이메일을 비동기로 발송합니다. 즉시 태스크 ID를 반환합니다."
)
def send_email_async(request, payload: SendEmailRequest):
    """
    비동기 이메일 발송 API
    
    Celery 태스크로 이메일을 발송하고 즉시 태스크 ID를 반환합니다.
    """
    from apps.emails.tasks import send_email_task
    
    try:
        task = send_email_task.delay(
            to_emails=payload.to_emails,
            subject=payload.subject,
            body_html=payload.body_html,
            body_text=payload.body_text,
            cc_emails=payload.cc_emails,
            bcc_emails=payload.bcc_emails
        )
        
        return {
            'task_id': task.id,
            'status': 'queued',
            'message': '이메일이 발송 대기열에 추가되었습니다'
        }
        
    except Exception as e:
        logger.exception("비동기 이메일 발송 실패")
        raise HttpError(400, str(e))


@router.get(
    "/task/{task_id}",
    response=dict,
    summary="태스크 상태 조회",
    description="비동기 이메일 발송 태스크의 상태를 조회합니다."
)
def get_task_status(request, task_id: str):
    """
    Celery 태스크 상태 조회 API
    
    - **task_id**: Celery 태스크 ID
    """
    from celery.result import AsyncResult
    
    task_result = AsyncResult(task_id)
    
    response = {
        'task_id': task_id,
        'status': task_result.status,
        'result': None
    }
    
    if task_result.ready():
        if task_result.successful():
            response['result'] = task_result.result
        else:
            response['error'] = str(task_result.info)
    
    return response

6.2 이메일 템플릿 시스템

Django 템플릿을 활용한 이메일 템플릿을 만들겠습니다.

{% raw %}

<!-- templates/emails/base.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
        body {
            font-family: 'Noto Sans KR', Arial, sans-serif;
            line-height: 1.6;
            color: #333;
            max-width: 600px;
            margin: 0 auto;
            padding: 20px;
        }
        .header {
            background-color: #4A90E2;
            color: white;
            padding: 20px;
            text-align: center;
            border-radius: 5px 5px 0 0;
        }
        .content {
            background-color: #f9f9f9;
            padding: 30px;
            border: 1px solid #ddd;
        }
        .footer {
            background-color: #333;
            color: #fff;
            padding: 15px;
            text-align: center;
            font-size: 12px;
            border-radius: 0 0 5px 5px;
        }
        .button {
            display: inline-block;
            padding: 12px 24px;
            background-color: #4A90E2;
            color: white;
            text-decoration: none;
            border-radius: 5px;
            margin: 20px 0;
        }
        .button:hover {
            background-color: #357ABD;
        }
    </style>
</head>
<body>
    <div class="header">
        <h1>{% block header_title %}알림{% endblock %}</h1>
    </div>
    <div class="content">
        {% block content %}{% endblock %}
    </div>
    <div class="footer">
        <p>&copy; 2025 Your Company. All rights reserved.</p>
        <p>
            <a href="#" style="color: #4A90E2;">수신거부</a> |
            <a href="#" style="color: #4A90E2;">개인정보처리방침</a>
        </p>
    </div>
</body>
</html>
<!-- templates/emails/notification.html -->
{% extends "emails/base.html" %}

{% block header_title %}{{ subject }}{% endblock %}

{% block content %}
<h2>안녕하세요, {{ username }}님!</h2>

<p>{{ message }}</p>

{% if action_url %}
<a href="{{ action_url }}" class="button">{{ action_text|default:"자세히 보기" }}</a>
{% endif %}

{% if items %}
<h3>항목 목록:</h3>
<ul>
    {% for item in items %}
    <li>{{ item }}</li>
    {% endfor %}
</ul>
{% endif %}

<p>
    문의사항이 있으시면 언제든지 연락주세요.<br>
    감사합니다.
</p>
{% endblock %}
<!-- templates/emails/notification.txt -->
{{ subject }}

안녕하세요, {{ username }}님!

{{ message }}

{% if action_url %}
자세히 보기: {{ action_url }}
{% endif %}

{% if items %}
항목 목록:
{% for item in items %}
- {{ item }}
{% endfor %}
{% endif %}

문의사항이 있으시면 언제든지 연락주세요.
감사합니다.

---
© 2025 Your Company. All rights reserved.

{% endraw %}

6.3 레이트 리미팅 및 쿼터 관리

# apps/emails/services/rate_limiter.py
from django.core.cache import cache
from django.conf import settings
from datetime import datetime, timedelta
import logging

logger = logging.getLogger(__name__)


class RateLimiter:
    """이메일 발송 속도 제한"""
    
    def __init__(self):
        self.cache_key_prefix = 'email_rate_limit'
    
    def check_rate_limit(self, identifier: str, limit: int = 100, period: int = 3600) -> bool:
        """
        레이트 리미트 체크
        
        Args:
            identifier: 식별자 (예: user_id, ip_address)
            limit: 제한 횟수
            period: 제한 기간 (초)
            
        Returns:
            True: 허용, False: 제한 초과
        """
        cache_key = f"{self.cache_key_prefix}:{identifier}"
        
        # 현재 카운트 조회
        current_count = cache.get(cache_key, 0)
        
        if current_count >= limit:
            logger.warning(f"레이트 리미트 초과: {identifier} ({current_count}/{limit})")
            return False
        
        # 카운트 증가
        if current_count == 0:
            # 첫 요청이면 TTL 설정
            cache.set(cache_key, 1, period)
        else:
            # 기존 TTL 유지하면서 증가
            cache.incr(cache_key)
        
        return True
    
    def get_remaining_quota(self, identifier: str, limit: int = 100) -> int:
        """
        남은 쿼터 조회
        
        Args:
            identifier: 식별자
            limit: 제한 횟수
            
        Returns:
            남은 발송 가능 횟수
        """
        cache_key = f"{self.cache_key_prefix}:{identifier}"
        current_count = cache.get(cache_key, 0)
        return max(0, limit - current_count)
    
    def reset_quota(self, identifier: str):
        """쿼터 초기화"""
        cache_key = f"{self.cache_key_prefix}:{identifier}"
        cache.delete(cache_key)
        logger.info(f"쿼터 초기화: {identifier}")


# API에 레이트 리미팅 적용
# apps/emails/api.py에 추가

from .services.rate_limiter import RateLimiter

rate_limiter = RateLimiter()

@router.post("/send")
def send_email(request, payload: SendEmailRequest):
    # 레이트 리미트 체크 (IP 기반)
    client_ip = request.META.get('REMOTE_ADDR')
    
    if not rate_limiter.check_rate_limit(
        identifier=f"ip:{client_ip}",
        limit=100,  # 1시간당 100건
        period=3600
    ):
        remaining = rate_limiter.get_remaining_quota(f"ip:{client_ip}", limit=100)
        raise HttpError(429, f"발송 제한을 초과했습니다. 남은 쿼터: {remaining}")
    
    # 기존 로직...

6.4 이메일 검증 및 블랙리스트

# apps/emails/services/email_validator.py
import re
from typing import Tuple
from django.core.cache import cache
import logging

logger = logging.getLogger(__name__)


class EmailValidator:
    """이메일 주소 검증"""
    
    # 일회용 이메일 도메인 (예시)
    DISPOSABLE_DOMAINS = {
        'tempmail.com', 'throwaway.email', '10minutemail.com',
        'guerrillamail.com', 'mailinator.com'
    }
    
    # 블랙리스트 캐시 키
    BLACKLIST_CACHE_KEY = 'email_blacklist'
    
    @staticmethod
    def is_valid_format(email: str) -> bool:
        """이메일 형식 검증"""
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        return bool(re.match(pattern, email))
    
    @classmethod
    def is_disposable(cls, email: str) -> bool:
        """일회용 이메일 주소 체크"""
        domain = email.split('@')[1].lower()
        return domain in cls.DISPOSABLE_DOMAINS
    
    @classmethod
    def is_blacklisted(cls, email: str) -> bool:
        """블랙리스트 체크"""
        blacklist = cache.get(cls.BLACKLIST_CACHE_KEY, set())
        return email.lower() in blacklist
    
    @classmethod
    def add_to_blacklist(cls, email: str):
        """블랙리스트에 추가"""
        blacklist = cache.get(cls.BLACKLIST_CACHE_KEY, set())
        blacklist.add(email.lower())
        cache.set(cls.BLACKLIST_CACHE_KEY, blacklist, timeout=None)
        logger.info(f"블랙리스트 추가: {email}")
    
    @classmethod
    def remove_from_blacklist(cls, email: str):
        """블랙리스트에서 제거"""
        blacklist = cache.get(cls.BLACKLIST_CACHE_KEY, set())
        blacklist.discard(email.lower())
        cache.set(cls.BLACKLIST_CACHE_KEY, blacklist, timeout=None)
        logger.info(f"블랙리스트 제거: {email}")
    
    @classmethod
    def validate(cls, email: str) -> Tuple[bool, str]:
        """
        종합 검증
        
        Returns:
            (유효성, 에러 메시지)
        """
        if not cls.is_valid_format(email):
            return False, "올바르지 않은 이메일 형식입니다"
        
        if cls.is_disposable(email):
            return False, "일회용 이메일 주소는 사용할 수 없습니다"
        
        if cls.is_blacklisted(email):
            return False, "차단된 이메일 주소입니다"
        
        return True, "OK"

이제 고급 기능들이 구현되었습니다. 다음 섹션에서는 테스트 코드를 작성하겠습니다.

7. 테스트 코드 작성

7.1 pytest 설정

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
addopts = --reuse-db --nomigrations -v
markers =
    unit: Unit tests
    integration: Integration tests
    slow: Tests that take a long time to run
# conftest.py
import pytest
from moto import mock_ses
import boto3
from django.conf import settings


@pytest.fixture
def mock_ses_client():
    """Mock SES 클라이언트"""
    with mock_ses():
        # SES 클라이언트 생성
        client = boto3.client(
            'ses',
            region_name=settings.AWS_REGION,
            aws_access_key_id='testing',
            aws_secret_access_key='testing'
        )
        
        # 테스트용 이메일 주소 인증
        client.verify_email_identity(EmailAddress=settings.AWS_SES_FROM_EMAIL)
        client.verify_email_identity(EmailAddress='test@example.com')
        
        yield client


@pytest.fixture
def sample_attachment():
    """샘플 첨부파일"""
    from apps.emails.services.email_service import EmailAttachment
    
    return EmailAttachment(
        filename='test.pdf',
        content=b'PDF content here',
        content_type='application/pdf'
    )


@pytest.fixture
def sample_email_data():
    """샘플 이메일 데이터"""
    return {
        'to_emails': ['test@example.com'],
        'subject': 'Test Email',
        'body_html': '<h1>Test</h1>',
        'body_text': 'Test'
    }

7.2 서비스 레이어 테스트

# apps/emails/tests/test_services.py
import pytest
from unittest.mock import Mock, patch, MagicMock
from botocore.exceptions import ClientError

from apps.emails.services.ses_client import SESClient, SESClientError
from apps.emails.services.email_service import EmailService, EmailAttachment
from apps.emails.models import EmailLog


@pytest.mark.django_db
class TestSESClient:
    """SES 클라이언트 테스트"""
    
    def test_send_raw_email_success(self, mock_ses_client):
        """정상 발송 테스트"""
        client = SESClient()
        
        message = b"From: test@example.com\nTo: test@example.com\nSubject: Test\n\nBody"
        
        response = client.send_raw_email(
            raw_message=message,
            source='test@example.com',
            destinations=['test@example.com']
        )
        
        assert 'MessageId' in response
        assert response['ResponseMetadata']['HTTPStatusCode'] == 200
    
    def test_send_raw_email_invalid_sender(self, mock_ses_client):
        """미인증 발신자 테스트"""
        client = SESClient()
        
        message = b"From: invalid@example.com\nTo: test@example.com\nSubject: Test\n\nBody"
        
        with pytest.raises(SESClientError) as exc_info:
            client.send_raw_email(
                raw_message=message,
                source='invalid@example.com',
                destinations=['test@example.com']
            )
        
        assert "인증" in str(exc_info.value).lower() or "verified" in str(exc_info.value).lower()
    
    def test_get_send_quota(self, mock_ses_client):
        """발송 쿼터 조회 테스트"""
        client = SESClient()
        quota = client.get_send_quota()
        
        assert 'Max24HourSend' in quota
        assert 'MaxSendRate' in quota
        assert 'SentLast24Hours' in quota


@pytest.mark.django_db
class TestEmailAttachment:
    """첨부파일 테스트"""
    
    def test_attachment_creation(self):
        """첨부파일 생성 테스트"""
        attachment = EmailAttachment(
            filename='test.pdf',
            content=b'PDF content',
            content_type='application/pdf'
        )
        
        assert attachment.filename == 'test.pdf'
        assert attachment.content == b'PDF content'
        assert attachment.content_type == 'application/pdf'
    
    def test_attachment_content_type_guessing(self):
        """Content-Type 자동 추측 테스트"""
        attachment = EmailAttachment(
            filename='image.png',
            content=b'PNG data'
        )
        
        assert attachment.content_type == 'image/png'
    
    def test_attachment_size_validation(self, settings):
        """파일 크기 검증 테스트"""
        settings.EMAIL_MAX_ATTACHMENT_SIZE = 100
        
        # 작은 파일 - 성공
        small_attachment = EmailAttachment(
            filename='small.txt',
            content=b'small'
        )
        is_valid, msg = small_attachment.validate()
        assert is_valid
        
        # 큰 파일 - 실패
        large_attachment = EmailAttachment(
            filename='large.txt',
            content=b'x' * 200
        )
        is_valid, msg = large_attachment.validate()
        assert not is_valid
        assert '초과' in msg
    
    def test_attachment_extension_validation(self, settings):
        """파일 확장자 검증 테스트"""
        settings.EMAIL_ALLOWED_EXTENSIONS = ['pdf', 'jpg']
        
        # 허용된 확장자
        valid_attachment = EmailAttachment(
            filename='doc.pdf',
            content=b'data'
        )
        is_valid, msg = valid_attachment.validate()
        assert is_valid
        
        # 허용되지 않은 확장자
        invalid_attachment = EmailAttachment(
            filename='script.exe',
            content=b'data'
        )
        is_valid, msg = invalid_attachment.validate()
        assert not is_valid
        assert '허용되지 않는' in msg


@pytest.mark.django_db
class TestEmailService:
    """이메일 서비스 테스트"""
    
    def test_send_email_success(self, mock_ses_client, sample_email_data):
        """이메일 발송 성공 테스트"""
        service = EmailService()
        
        email_log = service.send_email(**sample_email_data)
        
        assert email_log.status == EmailLog.Status.SENT
        assert email_log.message_id is not None
        assert email_log.to_emails == sample_email_data['to_emails']
        assert email_log.subject == sample_email_data['subject']
    
    def test_send_email_with_attachment(self, mock_ses_client, sample_email_data, sample_attachment):
        """첨부파일 포함 이메일 발송 테스트"""
        service = EmailService()
        
        email_log = service.send_email(
            **sample_email_data,
            attachments=[sample_attachment]
        )
        
        assert email_log.status == EmailLog.Status.SENT
        assert len(email_log.attachments) == 1
        assert email_log.attachments[0]['filename'] == 'test.pdf'
    
    def test_send_email_invalid_attachment(self, mock_ses_client, sample_email_data, settings):
        """잘못된 첨부파일 테스트"""
        settings.EMAIL_ALLOWED_EXTENSIONS = ['pdf']
        
        service = EmailService()
        
        invalid_attachment = EmailAttachment(
            filename='virus.exe',
            content=b'malicious'
        )
        
        with pytest.raises(ValueError) as exc_info:
            service.send_email(
                **sample_email_data,
                attachments=[invalid_attachment]
            )
        
        assert '허용되지 않는' in str(exc_info.value)
    
    def test_send_email_failure_logging(self, sample_email_data):
        """발송 실패 로깅 테스트"""
        service = EmailService()
        
        # SES 클라이언트를 Mock으로 대체하여 에러 발생시키기
        with patch.object(service.ses_client, 'send_raw_email') as mock_send:
            mock_send.side_effect = SESClientError("Test error")
            
            with pytest.raises(SESClientError):
                service.send_email(**sample_email_data)
            
            # 실패 로그 확인
            failed_log = EmailLog.objects.filter(status=EmailLog.Status.FAILED).first()
            assert failed_log is not None
            assert failed_log.error_message == "Test error"
    
    def test_send_templated_email(self, mock_ses_client):
        """템플릿 이메일 발송 테스트"""
        service = EmailService()
        
        email_log = service.send_templated_email(
            to_emails=['test@example.com'],
            template_name='notification',
            context={
                'subject': 'Test Notification',
                'username': 'John Doe',
                'message': 'This is a test message'
            }
        )
        
        assert email_log.status == EmailLog.Status.SENT
        assert 'John Doe' in email_log.body_html


@pytest.mark.django_db
class TestEmailValidator:
    """이메일 검증 테스트"""
    
    def test_valid_email_format(self):
        """올바른 이메일 형식 테스트"""
        from apps.emails.services.email_validator import EmailValidator
        
        assert EmailValidator.is_valid_format('test@example.com')
        assert EmailValidator.is_valid_format('user.name+tag@example.co.kr')
        assert not EmailValidator.is_valid_format('invalid.email')
        assert not EmailValidator.is_valid_format('@example.com')
    
    def test_disposable_email_detection(self):
        """일회용 이메일 감지 테스트"""
        from apps.emails.services.email_validator import EmailValidator
        
        assert EmailValidator.is_disposable('test@tempmail.com')
        assert not EmailValidator.is_disposable('test@gmail.com')
    
    def test_blacklist_management(self):
        """블랙리스트 관리 테스트"""
        from apps.emails.services.email_validator import EmailValidator
        
        email = 'spam@example.com'
        
        # 블랙리스트 추가
        EmailValidator.add_to_blacklist(email)
        assert EmailValidator.is_blacklisted(email)
        
        # 블랙리스트 제거
        EmailValidator.remove_from_blacklist(email)
        assert not EmailValidator.is_blacklisted(email)


@pytest.mark.django_db
class TestRateLimiter:
    """레이트 리미터 테스트"""
    
    def test_rate_limit_basic(self):
        """기본 레이트 리미팅 테스트"""
        from apps.emails.services.rate_limiter import RateLimiter
        
        limiter = RateLimiter()
        identifier = 'test_user'
        
        # 처음 몇 번은 허용
        for i in range(5):
            assert limiter.check_rate_limit(identifier, limit=5, period=60)
        
        # 제한 초과
        assert not limiter.check_rate_limit(identifier, limit=5, period=60)
    
    def test_rate_limit_quota_check(self):
        """남은 쿼터 확인 테스트"""
        from apps.emails.services.rate_limiter import RateLimiter
        
        limiter = RateLimiter()
        identifier = 'test_user_2'
        
        # 3번 사용
        for i in range(3):
            limiter.check_rate_limit(identifier, limit=10, period=60)
        
        # 남은 쿼터는 7
        remaining = limiter.get_remaining_quota(identifier, limit=10)
        assert remaining == 7

7.3 API 엔드포인트 테스트

# apps/emails/tests/test_api.py
import pytest
import json
from django.test import Client
from unittest.mock import patch, Mock
import io

from apps.emails.models import EmailLog


@pytest.mark.django_db
class TestEmailAPI:
    """이메일 API 테스트"""
    
    @pytest.fixture
    def client(self):
        return Client()
    
    def test_send_email_endpoint(self, client, mock_ses_client):
        """이메일 발송 엔드포인트 테스트"""
        response = client.post(
            '/api/emails/send',
            data=json.dumps({
                'to_emails': ['test@example.com'],
                'subject': 'Test Email',
                'body_html': '<h1>Test</h1>',
                'body_text': 'Test'
            }),
            content_type='application/json'
        )
        
        assert response.status_code == 200
        data = response.json()
        assert 'id' in data
        assert data['status'] == 'sent'
    
    def test_send_email_validation_error(self, client):
        """잘못된 요청 검증 테스트"""
        response = client.post(
            '/api/emails/send',
            data=json.dumps({
                'to_emails': [],  # 빈 리스트
                'subject': 'Test'
            }),
            content_type='application/json'
        )
        
        assert response.status_code == 422  # Validation error
    
    def test_send_email_with_attachments_endpoint(self, client, mock_ses_client):
        """첨부파일 포함 발송 엔드포인트 테스트"""
        # 테스트 파일 생성
        test_file = io.BytesIO(b'test file content')
        test_file.name = 'test.txt'
        
        response = client.post(
            '/api/emails/send-with-attachments',
            data={
                'to_emails': 'test@example.com',
                'subject': 'Test with Attachment',
                'body_html': '<h1>Test</h1>',
                'attachments': test_file
            }
        )
        
        assert response.status_code == 200
        data = response.json()
        assert data['status'] == 'sent'
    
    def test_get_email_log_endpoint(self, client, mock_ses_client):
        """이메일 로그 조회 엔드포인트 테스트"""
        # 먼저 이메일 발송
        from apps.emails.services.email_service import EmailService
        service = EmailService()
        email_log = service.send_email(
            to_emails=['test@example.com'],
            subject='Test',
            body_text='Test'
        )
        
        # 로그 조회
        response = client.get(f'/api/emails/logs/{email_log.id}')
        
        assert response.status_code == 200
        data = response.json()
        assert data['id'] == str(email_log.id)
        assert data['subject'] == 'Test'
    
    def test_list_email_logs_endpoint(self, client, mock_ses_client):
        """이메일 로그 목록 엔드포인트 테스트"""
        # 여러 이메일 발송
        from apps.emails.services.email_service import EmailService
        service = EmailService()
        
        for i in range(5):
            service.send_email(
                to_emails=['test@example.com'],
                subject=f'Test {i}',
                body_text='Test'
            )
        
        # 목록 조회
        response = client.get('/api/emails/logs?limit=10')
        
        assert response.status_code == 200
        data = response.json()
        assert len(data) == 5
    
    def test_get_quota_endpoint(self, client, mock_ses_client):
        """쿼터 조회 엔드포인트 테스트"""
        response = client.get('/api/emails/quota')
        
        assert response.status_code == 200
        data = response.json()
        assert 'max_24_hour_send' in data
        assert 'max_send_rate' in data
        assert 'sent_last_24_hours' in data
    
    def test_rate_limiting(self, client, mock_ses_client):
        """레이트 리미팅 테스트"""
        # 짧은 시간에 많은 요청
        for i in range(101):  # 제한: 100
            response = client.post(
                '/api/emails/send',
                data=json.dumps({
                    'to_emails': ['test@example.com'],
                    'subject': f'Test {i}',
                    'body_text': 'Test'
                }),
                content_type='application/json',
                REMOTE_ADDR='127.0.0.1'
            )
            
            if i < 100:
                assert response.status_code == 200
            else:
                assert response.status_code == 429  # Too Many Requests


@pytest.mark.django_db
class TestAsyncEmailAPI:
    """비동기 이메일 API 테스트"""
    
    @pytest.fixture
    def client(self):
        return Client()
    
    @patch('apps.emails.tasks.send_email_task.delay')
    def test_send_email_async_endpoint(self, mock_task, client):
        """비동기 발송 엔드포인트 테스트"""
        mock_task.return_value = Mock(id='test-task-id')
        
        response = client.post(
            '/api/emails/send-async',
            data=json.dumps({
                'to_emails': ['test@example.com'],
                'subject': 'Async Test',
                'body_text': 'Test'
            }),
            content_type='application/json'
        )
        
        assert response.status_code == 202
        data = response.json()
        assert data['task_id'] == 'test-task-id'
        assert data['status'] == 'queued'
        
        # Celery 태스크가 호출되었는지 확인
        mock_task.assert_called_once()


# 테스트 실행
# pytest apps/emails/tests/ -v
# pytest apps/emails/tests/test_services.py::TestEmailService -v
# pytest apps/emails/tests/ --cov=apps.emails --cov-report=html

7.4 커버리지 확인

# 테스트 실행 및 커버리지 확인
pytest apps/emails/tests/ --cov=apps.emails --cov-report=html --cov-report=term

# 결과 예시:
# Name                                    Stmts   Miss  Cover
# -----------------------------------------------------------
# apps/emails/__init__.py                     0      0   100%
# apps/emails/models.py                      45      2    96%
# apps/emails/schemas.py                     38      1    97%
# apps/emails/services/ses_client.py         72      5    93%
# apps/emails/services/email_service.py     128      8    94%
# apps/emails/api.py                         95      6    94%
# -----------------------------------------------------------
# TOTAL                                     378     22    94%

이제 테스트 코드가 완성되었습니다. 다음 섹션에서는 프로덕션 배포 가이드를 작성하겠습니다.

8. 프로덕션 배포 가이드

8.1 보안 설정

1) AWS IAM 역할 기반 인증 (EC2/ECS)

하드코딩된 Access Key 대신 IAM 역할을 사용하는 것이 더 안전합니다.

# config/settings.py - 프로덕션 설정
import boto3
from botocore.exceptions import NoCredentialsError

# IAM 역할 사용 시 (EC2/ECS)
try:
    # 자격 증명을 명시하지 않으면 IAM 역할 사용
    session = boto3.Session()
    AWS_ACCESS_KEY_ID = None
    AWS_SECRET_ACCESS_KEY = None
except NoCredentialsError:
    # Fallback to environment variables
    AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
    AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')

2) AWS Secrets Manager 사용

# apps/emails/utils/secrets.py
import boto3
import json
from django.conf import settings


def get_secret(secret_name: str) -> dict:
    """AWS Secrets Manager에서 시크릿 가져오기"""
    client = boto3.client(
        'secretsmanager',
        region_name=settings.AWS_REGION
    )
    
    try:
        response = client.get_secret_value(SecretId=secret_name)
        return json.loads(response['SecretString'])
    except Exception as e:
        raise Exception(f"시크릿 가져오기 실패: {str(e)}")


# 사용 예시
# secrets = get_secret('prod/email-service')
# AWS_ACCESS_KEY_ID = secrets['aws_access_key_id']

3) 환경별 설정 분리

# config/settings/base.py
from pathlib import Path
import os

BASE_DIR = Path(__file__).resolve().parent.parent.parent

# ... 공통 설정 ...
# config/settings/production.py
from .base import *

DEBUG = False
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split(',')

# 보안 설정
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

# CORS 설정
CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',')

# 데이터베이스 - RDS 사용
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME'),
        'USER': os.getenv('DB_USER'),
        'PASSWORD': os.getenv('DB_PASSWORD'),
        'HOST': os.getenv('DB_HOST'),
        'PORT': os.getenv('DB_PORT', '5432'),
        'CONN_MAX_AGE': 600,
        'OPTIONS': {
            'sslmode': 'require',
        }
    }
}

# Redis - ElastiCache 사용
CELERY_BROKER_URL = os.getenv('REDIS_URL')
CELERY_RESULT_BACKEND = os.getenv('REDIS_URL')

# 로깅 - CloudWatch로 전송
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'json': {
            '()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
            'format': '%(asctime)s %(name)s %(levelname)s %(message)s'
        }
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'formatter': 'json',
        },
        'watchtower': {
            'class': 'watchtower.CloudWatchLogHandler',
            'log_group': '/aws/email-service',
            'stream_name': 'production',
        }
    },
    'loggers': {
        'apps.emails': {
            'handlers': ['console', 'watchtower'],
            'level': 'INFO',
        },
    },
}

8.2 모니터링 및 알림

1) CloudWatch 메트릭 전송

# apps/emails/services/metrics.py
import boto3
from django.conf import settings
from datetime import datetime
import logging

logger = logging.getLogger(__name__)


class MetricsClient:
    """CloudWatch 메트릭 클라이언트"""
    
    def __init__(self):
        self.client = boto3.client(
            'cloudwatch',
            region_name=settings.AWS_REGION
        )
        self.namespace = 'EmailService'
    
    def put_metric(self, metric_name: str, value: float, unit: str = 'Count', **dimensions):
        """메트릭 전송"""
        try:
            self.client.put_metric_data(
                Namespace=self.namespace,
                MetricData=[
                    {
                        'MetricName': metric_name,
                        'Value': value,
                        'Unit': unit,
                        'Timestamp': datetime.utcnow(),
                        'Dimensions': [
                            {'Name': k, 'Value': v}
                            for k, v in dimensions.items()
                        ]
                    }
                ]
            )
        except Exception as e:
            logger.error(f"메트릭 전송 실패: {str(e)}")
    
    def record_email_sent(self, status: str):
        """이메일 발송 메트릭"""
        self.put_metric('EmailSent', 1, Status=status)
    
    def record_send_duration(self, duration_ms: float):
        """발송 소요 시간 메트릭"""
        self.put_metric('SendDuration', duration_ms, unit='Milliseconds')
    
    def record_attachment_size(self, size_bytes: int):
        """첨부파일 크기 메트릭"""
        self.put_metric('AttachmentSize', size_bytes, unit='Bytes')


# 이메일 서비스에 메트릭 추가
# apps/emails/services/email_service.py에 추가

from .metrics import MetricsClient
import time

class EmailService:
    def __init__(self):
        self.ses_client = SESClient()
        self.metrics = MetricsClient()
    
    def send_email(self, ...):
        start_time = time.time()
        
        try:
            # ... 기존 로직 ...
            
            # 성공 메트릭
            duration_ms = (time.time() - start_time) * 1000
            self.metrics.record_email_sent('success')
            self.metrics.record_send_duration(duration_ms)
            
            return email_log
            
        except Exception as e:
            # 실패 메트릭
            self.metrics.record_email_sent('failed')
            raise

2) SNS 알림 설정

# apps/emails/services/notifications.py
import boto3
from django.conf import settings
import logging

logger = logging.getLogger(__name__)


class SNSNotifier:
    """SNS 알림 클라이언트"""
    
    def __init__(self):
        self.client = boto3.client(
            'sns',
            region_name=settings.AWS_REGION
        )
        self.topic_arn = settings.SNS_ALERT_TOPIC_ARN
    
    def send_alert(self, subject: str, message: str):
        """알림 전송"""
        try:
            self.client.publish(
                TopicArn=self.topic_arn,
                Subject=subject,
                Message=message
            )
            logger.info(f"알림 전송: {subject}")
        except Exception as e:
            logger.error(f"알림 전송 실패: {str(e)}")
    
    def send_error_alert(self, error: Exception, context: dict):
        """에러 알림"""
        message = f"""
이메일 발송 에러 발생

에러: {str(error)}
컨텍스트: {context}

즉시 확인이 필요합니다.
        """
        self.send_alert("이메일 서비스 에러", message)


# Celery 태스크에서 사용
from .notifications import SNSNotifier

@shared_task
def send_email_task(...):
    notifier = SNSNotifier()
    
    try:
        # ... 발송 로직 ...
        pass
    except Exception as e:
        notifier.send_error_alert(e, {'to_emails': to_emails})
        raise

3) SES 이벤트 추적 (SNS 웹훅)

# apps/emails/views.py - SES 이벤트 수신
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
import json
import logging

logger = logging.getLogger(__name__)


@csrf_exempt
def ses_webhook(request):
    """
    SES 이벤트 웹훅 핸들러
    
    Bounce, Complaint 등의 이벤트를 수신하여 처리
    """
    if request.method != 'POST':
        return JsonResponse({'error': 'Method not allowed'}, status=405)
    
    try:
        # SNS 메시지 파싱
        message = json.loads(request.body)
        
        # 구독 확인
        if message.get('Type') == 'SubscriptionConfirmation':
            # SubscribeURL 방문하여 구독 확인
            import requests
            requests.get(message['SubscribeURL'])
            logger.info("SNS 구독 확인 완료")
            return JsonResponse({'status': 'confirmed'})
        
        # 알림 메시지 처리
        if message.get('Type') == 'Notification':
            ses_message = json.loads(message['Message'])
            event_type = ses_message.get('eventType')
            
            if event_type == 'Bounce':
                handle_bounce(ses_message)
            elif event_type == 'Complaint':
                handle_complaint(ses_message)
            elif event_type == 'Delivery':
                handle_delivery(ses_message)
            
            return JsonResponse({'status': 'processed'})
        
    except Exception as e:
        logger.error(f"SES 웹훅 처리 실패: {str(e)}")
        return JsonResponse({'error': str(e)}, status=500)


def handle_bounce(message):
    """바운스 처리"""
    bounce = message['bounce']
    bounced_recipients = bounce['bouncedRecipients']
    
    for recipient in bounced_recipients:
        email = recipient['emailAddress']
        
        # 이메일 로그 업데이트
        EmailLog.objects.filter(
            to_emails__contains=[email],
            message_id=message['mail']['messageId']
        ).update(status=EmailLog.Status.BOUNCED)
        
        # 블랙리스트 추가 (Hard bounce의 경우)
        if bounce['bounceType'] == 'Permanent':
            from apps.emails.services.email_validator import EmailValidator
            EmailValidator.add_to_blacklist(email)
            logger.warning(f"영구 바운스로 블랙리스트 추가: {email}")


def handle_complaint(message):
    """스팸 신고 처리"""
    complaint = message['complaint']
    complained_recipients = complaint['complainedRecipients']
    
    for recipient in complained_recipients:
        email = recipient['emailAddress']
        
        # 이메일 로그 업데이트
        EmailLog.objects.filter(
            to_emails__contains=[email],
            message_id=message['mail']['messageId']
        ).update(status=EmailLog.Status.COMPLAINED)
        
        # 블랙리스트 추가
        from apps.emails.services.email_validator import EmailValidator
        EmailValidator.add_to_blacklist(email)
        logger.warning(f"스팸 신고로 블랙리스트 추가: {email}")


def handle_delivery(message):
    """전송 성공 처리"""
    logger.info(f"이메일 전송 성공: {message['mail']['messageId']}")

8.3 Docker 배포

# Dockerfile
FROM python:3.11-slim

# 환경 변수
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1

# 작업 디렉토리
WORKDIR /app

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

# Python 패키지 설치
COPY requirements.txt .
RUN pip install --upgrade pip && \
    pip install -r requirements.txt

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

# 정적 파일 수집
RUN python manage.py collectstatic --noinput

# Gunicorn으로 실행
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
# docker-compose.yml
version: '3.8'

services:
  web:
    build: .
    command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4
    volumes:
      - .:/app
    ports:
      - "8000:8000"
    env_file:
      - .env
    depends_on:
      - db
      - redis
  
  db:
    image: postgres:15
    environment:
      POSTGRES_DB: email_service
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data
  
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
  
  celery:
    build: .
    command: celery -A config worker -l info
    volumes:
      - .:/app
    env_file:
      - .env
    depends_on:
      - db
      - redis
  
  celery-beat:
    build: .
    command: celery -A config beat -l info
    volumes:
      - .:/app
    env_file:
      - .env
    depends_on:
      - db
      - redis

volumes:
  postgres_data:

8.4 성능 최적화

1) 커넥션 풀링

# config/settings/production.py

# PostgreSQL 커넥션 풀링
DATABASES = {
    'default': {
        # ...
        'CONN_MAX_AGE': 600,  # 10분
        'OPTIONS': {
            'connect_timeout': 10,
            'options': '-c statement_timeout=30000'  # 30초
        }
    }
}

# Redis 커넥션 풀
CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': os.getenv('REDIS_URL'),
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
            'CONNECTION_POOL_KWARGS': {
                'max_connections': 50,
                'retry_on_timeout': True
            }
        }
    }
}

2) 이메일 배치 처리

# apps/emails/services/batch_processor.py
from typing import List
import time
from django.db import transaction


class EmailBatchProcessor:
    """이메일 배치 처리"""
    
    def __init__(self, batch_size: int = 50):
        self.batch_size = batch_size
        self.email_service = EmailService()
    
    def process_batch(self, emails: List[dict]) -> dict:
        """
        이메일 배치 발송
        
        Args:
            emails: 이메일 데이터 리스트
            
        Returns:
            발송 결과 통계
        """
        results = {
            'total': len(emails),
            'success': 0,
            'failed': 0
        }
        
        # 배치 단위로 처리
        for i in range(0, len(emails), self.batch_size):
            batch = emails[i:i + self.batch_size]
            
            with transaction.atomic():
                for email_data in batch:
                    try:
                        self.email_service.send_email(**email_data)
                        results['success'] += 1
                    except Exception as e:
                        results['failed'] += 1
                        logger.error(f"배치 발송 실패: {str(e)}")
            
            # Rate limit 고려
            time.sleep(1)  # 배치 간 1초 대기
        
        return results

3) 캐싱 전략

# apps/emails/services/cache_service.py
from django.core.cache import cache
from functools import wraps


def cache_email_template(timeout=3600):
    """이메일 템플릿 캐싱 데코레이터"""
    def decorator(func):
        @wraps(func)
        def wrapper(template_name, *args, **kwargs):
            cache_key = f'email_template:{template_name}'
            result = cache.get(cache_key)
            
            if result is None:
                result = func(template_name, *args, **kwargs)
                cache.set(cache_key, result, timeout)
            
            return result
        return wrapper
    return decorator


@cache_email_template(timeout=3600)
def get_rendered_template(template_name, context):
    """템플릿 렌더링 (캐시 적용)"""
    from django.template.loader import render_to_string
    return render_to_string(f'emails/{template_name}.html', context)

이제 프로덕션 배포 가이드가 완성되었습니다. 마지막으로 결론을 작성하겠습니다.

9. 결론

9.1 주요 구현 내용 요약

이 가이드에서는 Django Ninja와 AWS SES를 활용하여 프로덕션 수준의 이메일 발송 API를 구축했습니다:

핵심 기능:

  • ✅ AWS SES 통합 및 첨부파일 지원
  • ✅ RESTful API (Django Ninja)
  • ✅ 비동기 처리 (Celery)
  • ✅ 이메일 템플릿 시스템
  • ✅ 레이트 리미팅 및 쿼터 관리
  • ✅ 이메일 검증 및 블랙리스트
  • ✅ 에러 핸들링 및 재시도 로직
  • ✅ 포괄적인 테스트 코드
  • ✅ 모니터링 및 알림 시스템
  • ✅ Docker 기반 배포

9.2 베스트 프랙티스

1) 보안

  • IAM 역할 기반 인증 사용
  • 민감 정보는 Secrets Manager 활용
  • HTTPS 및 CORS 설정
  • 입력 데이터 검증 철저히 수행

2) 성능

  • 비동기 처리로 응답 시간 단축
  • 커넥션 풀링으로 리소스 효율화
  • 템플릿 캐싱으로 렌더링 속도 향상
  • 배치 처리로 대량 발송 최적화

3) 안정성

  • 재시도 로직 및 지수 백오프
  • 에러 로깅 및 알림
  • SES 이벤트 추적 (Bounce, Complaint)
  • 레이트 리미팅으로 서비스 보호

4) 모니터링

  • CloudWatch 메트릭 수집
  • 발송 상태 로깅
  • 에러 알림 (SNS)
  • 성능 추적

9.3 확장 가능성

이 시스템은 다음과 같이 확장할 수 있습니다:

기능 확장:

  • 이메일 스케줄링 (특정 시간에 발송)
  • A/B 테스트 (여러 버전 테스트)
  • 이메일 트래킹 (열람, 클릭 추적)
  • 자동 응답 및 시퀀스
  • 다국어 템플릿 지원

인프라 확장:

  • Kubernetes 배포
  • 멀티 리전 구성
  • 로드 밸런싱
  • Auto Scaling
  • CDN을 통한 정적 파일 제공

통합 확장:

  • Slack/Discord 알림
  • CRM 시스템 연동
  • 분석 도구 통합 (Google Analytics)
  • 마케팅 자동화 플랫폼 연결

9.4 비용 최적화 팁

AWS SES 비용:

  • 무료 티어: 월 62,000건
  • 유료: $0.10/1,000건
  • 첨부파일: 추가 비용 없음
  • 전송 대역폭: EC2 내부에서 SES 호출 시 무료

예상 비용 (월 100만 건 기준):

SES: (1,000,000 - 62,000) × $0.10 / 1,000 = $93.80
EC2 (t3.medium): ~$30
RDS (db.t3.micro): ~$15
ElastiCache (cache.t3.micro): ~$12
------------------------
총 예상 비용: ~$150/월

비용 절감 전략:

  • 템플릿 캐싱으로 CPU 사용량 감소
  • 배치 처리로 API 호출 최소화
  • Reserved Instance 활용
  • 불필요한 로그 줄이기

9.5 트러블슈팅 가이드

문제 1: “MessageRejected” 에러

원인: 발신자 이메일/도메인 미인증
해결: AWS SES 콘솔에서 이메일 주소 또는 도메인 인증

문제 2: “MailFromDomainNotVerified” 에러

원인: MAIL FROM 도메인 미설정
해결: SES에서 Custom MAIL FROM 도메인 설정

문제 3: 첨부파일이 손상됨

원인: 인코딩 문제
해결: MIME 파트 생성 시 올바른 Content-Type 설정

문제 4: Celery 태스크가 실행되지 않음

원인: Celery worker 미실행 또는 Redis 연결 문제
해결: 
1. Celery worker 상태 확인: celery -A config inspect active
2. Redis 연결 확인: redis-cli ping

문제 5: 높은 바운스율

원인: 잘못된 이메일 주소 또는 스팸으로 분류
해결:
1. 이메일 검증 강화
2. SPF, DKIM, DMARC 레코드 확인
3. 발송 빈도 조절
4. 수신자 참여 유도 (옵트인)

9.6 참고 자료

공식 문서:

관련 RFC:

유용한 도구:

커뮤니티:

9.7 마치며

이메일은 여전히 가장 신뢰할 수 있는 커뮤니케이션 채널입니다. 이 가이드에서 구축한 시스템은 다음과 같은 장점을 제공합니다:

비즈니스 가치:

  • 📊 확장 가능한 아키텍처
  • 💰 비용 효율적인 솔루션
  • 🔒 엔터프라이즈급 보안
  • 📈 데이터 기반 의사결정 (로깅, 메트릭)

개발자 경험:

  • 🚀 빠른 개발 속도 (Django Ninja)
  • 🧪 테스트 가능한 코드
  • 📚 자동 API 문서화
  • 🔧 유지보수 용이

운영 관점:

  • 🎯 높은 전달률 (AWS SES)
  • 📊 실시간 모니터링
  • 🔄 자동 재시도 및 복구
  • 🛡️ 에러 추적 및 알림

이제 여러분의 프로젝트에 이 시스템을 적용하고, 필요에 따라 커스터마이징하여 사용하시기 바랍니다.

질문이나 피드백이 있으시면 댓글로 남겨주세요! 🚀


전체 코드는 GitHub에서 확인할 수 있습니다:

  • Repository: https://github.com/yourusername/django-ses-email-service
  • Branch: main
  • License: MIT

다음 포스트 예고:

  • Django Ninja로 실시간 채팅 API 구현하기
  • AWS Lambda + Django로 서버리스 백엔드 만들기
  • Django에서 WebSocket과 Channels 활용하기

이 글을 공유해보세요!