Files
Aislo/.agent/complete_and_old/auth_email_infrastructure_plan.md
T

25 KiB

이메일 인프라 구현 계획서 (v1.0)

1. 개요

1.1 목표

  • B2B SaaS 환경에서 자동 이메일 발송 시스템 구축
  • Google Workspace (개인용 Gmail 대체) 기반 SMTP 연동
  • 비동기 태스크 기반 논블로킹 발송
  • Phase 1: Google SMTP → Phase 2: AWS SES 마이그레이션 준비

1.2 현재 상황

  • 개발 단계: 개인 Gmail 계정 (umsangdon@gmail.com) 사용
  • 상용화 단계: Google Workspace Business 또는 AWS SES로 전환 예정
  • 타겟: 회원가입, 로그인, 팀원 승인/거부 등 자동 메일

2. 이메일 발송 유형 및 우선순위

2.1 필수 (🔴) - Phase 1에서 구현

메일 유형 수신자 발송 타이밍 용도
OTP 인증 가입자/로그인자 가입/로그인 직후 이메일 소유권 확인
가입 확인 신규 가입자 가입 완료 직후 가입 성공 안내
팀원 승인 요청 마스터 팀원 가입 신청 시 승인/거부 요청
팀원 승인/거부 결과 팀원 마스터 승인/거부 직후 결과 안내

2.2 중요 (🟠) - Phase 2에서 구현

메일 유형 수신자 발송 타이밍 용도
3개월 재인증 알림 모든 사용자 주기적 (크론) 보안 점검
비밀번호 변경 알림 해당 사용자 변경 직후 보안 알림
Q&A 답변 회신 문의자 관리자 처리 후 고객 지원

2.3 선택사항 (🟡) - Phase 3 이후

메일 유형 수신자 발송 타이밍 용도
세션 만료 알림 활동 중인 사용자 만료 5분 전 UX 개선
주간 활동 리포트 마스터 매주 월요일 분석 정보
결제/구독 안내 마스터 결제 시기 구독 관리

3. 아키텍처 설계

3.1 전체 흐름도

┌─────────────────────────────────────────────────────┐
│            FastAPI 웹 서버 (Uvicorn)                │
│                                                     │
│  ┌──────────────────────────────────────────────┐  │
│  │ API 라우터                                   │  │
│  │ - POST /api/auth/register                   │  │
│  │ - POST /api/auth/login                      │  │
│  │ - POST /api/auth/join-request               │  │
│  │ - POST /api/admin/approve-join              │  │
│  └──────────────────────────────────────────────┘  │
│                    │                                │
│                    ↓ (즉시 응답)                    │
│  ┌──────────────────────────────────────────────┐  │
│  │ 백그라운드 비동기 작업                         │  │
│  │ (asyncio.create_task)                       │  │
│  │                                              │  │
│  │ ├─ OTP 생성 + 암호화 저장                    │  │
│  │ ├─ 이메일 템플릿 렌더링                       │  │
│  │ └─ SMTP 발송 시작                            │  │
│  └──────────────────────────────────────────────┘  │
│                    │                                │
│                    ↓                                │
│  ┌──────────────────────────────────────────────┐  │
│  │ 이메일 발송 모듈                              │  │
│  │ (common_util/common_util_email.py)          │  │
│  │                                              │  │
│  │ ├─ SMTP 클라이언트 (Google)                  │  │
│  │ ├─ 재시도 로직 (최대 3회)                    │  │
│  │ ├─ 로깅 및 모니터링                          │  │
│  │ └─ 발송 이력 DB 저장                         │  │
│  └──────────────────────────────────────────────┘  │
│                    │                                │
└────────────────────┼────────────────────────────────┘
                     ↓
    ┌──────────────────────────────┐
    │   Google Gmail SMTP           │
    │   (smtp.gmail.com:587)        │
    │   + TLS 암호화                │
    └──────────────────────────────┘
                     │
                     ↓
    ┌──────────────────────────────┐
    │   수신자 이메일 서버           │
    │   (Gmail, Naver, etc)         │
    └──────────────────────────────┘

3.2 개발 단계 상세 흐름

1단계: 사용자가 회원가입 버튼 클릭
   ↓
2단계: FastAPI 라우터 수신
   - 이메일/비밀번호 검증 (Pydantic)
   - 중복 확인
   - DB에 임시 사용자 생성 (status = PENDING)
   ↓
3단계: 백그라운드 비동기 작업 시작 (즉시 응답)
   - OTP 코드 생성 (6자리 난수)
   - OTP 해시값 DB 저장 + 만료시간 기록 (5분)
   - 이메일 HTML 템플릿 렌더링
   ↓
4단계: 이메일 발송 (재시도 로직 포함)
   - 시도 1: SMTP 연결 및 발송
   - 실패 시 2초 대기 후 재시도
   - 3회 모두 실패 → 로그 기록 + 관리자 알림
   ↓
5단계: 클라이언트는 즉시 응답 받음
   - "인증 코드를 이메일로 발송했습니다"
   - 사용자는 이메일 확인하여 OTP 입력

4. 개발 단계 설정 (Phase 1)

4.1 Google Gmail SMTP 설정

단계 1: 앱 비밀번호 생성

Google 계정 (umsangdon@gmail.com) 접속
  ↓
Google 계정 관리 페이지 (myaccount.google.com)
  ↓
왼쪽 메뉴: "보안" (Security)
  ↓
2단계 인증 활성화 (이미 되어있으면 스킵)
  ↓
앱 비밀번호 (App passwords)
  ├─ 앱: "메일(Mail)"
  ├─ 기기: "Windows"
  └─ [생성] 버튼
  ↓
16자리 비밀번호 생성 (예: abcd efgh ijkl mnop)
  ↓
.env 파일에 저장

단계 2: .env 파일 작성

# config/.env (개발용, .gitignore에 포함)

# ===== Email Infrastructure (Phase 1) =====
SMTP_PROVIDER=google
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=umsangdon@gmail.com
SMTP_PASSWORD=abcd efgh ijkl mnop  # 위에서 생성한 앱 비밀번호
SMTP_USE_TLS=true

# 발송 이메일 주소
EMAIL_FROM_ADDRESS=umsangdon@gmail.com
EMAIL_FROM_NAME=Aislo Support
EMAIL_NOREPLY_ADDRESS=umsangdon+noreply@gmail.com  # 답장 불가 계정

# 이메일 발송 설정
EMAIL_RETRY_COUNT=3
EMAIL_RETRY_DELAY_SECONDS=2
EMAIL_OTP_VALID_MINUTES=5
EMAIL_NOTIFICATION_ENABLED=true

# 관리자 이메일 (에러 발생 시 수신)
ADMIN_EMAIL=umsangdon@gmail.com

단계 3: config_system.py 수정

# config/config_system.py

import os
from dotenv import load_dotenv

load_dotenv()

# ===== Email Configuration =====
SMTP_PROVIDER = os.getenv("SMTP_PROVIDER", "google")
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "umsangdon@gmail.com")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"

EMAIL_FROM_ADDRESS = os.getenv("EMAIL_FROM_ADDRESS", "umsangdon@gmail.com")
EMAIL_FROM_NAME = os.getenv("EMAIL_FROM_NAME", "Aislo Support")
EMAIL_NOREPLY_ADDRESS = os.getenv("EMAIL_NOREPLY_ADDRESS", "umsangdon+noreply@gmail.com")

EMAIL_RETRY_COUNT = int(os.getenv("EMAIL_RETRY_COUNT", "3"))
EMAIL_RETRY_DELAY_SECONDS = int(os.getenv("EMAIL_RETRY_DELAY_SECONDS", "2"))
EMAIL_OTP_VALID_MINUTES = int(os.getenv("EMAIL_OTP_VALID_MINUTES", "5"))
EMAIL_NOTIFICATION_ENABLED = os.getenv("EMAIL_NOTIFICATION_ENABLED", "true").lower() == "true"

ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "umsangdon@gmail.com")

5. 이메일 발송 모듈 (common_util_email.py)

5.1 기본 구조

# common_util/common_util_email.py

import smtplib
import asyncio
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
from config.config_system import (
    SMTP_HOST,
    SMTP_PORT,
    SMTP_USERNAME,
    SMTP_PASSWORD,
    SMTP_USE_TLS,
    EMAIL_FROM_ADDRESS,
    EMAIL_FROM_NAME,
    EMAIL_RETRY_COUNT,
    EMAIL_RETRY_DELAY_SECONDS,
)

logger = logging.getLogger(__name__)

class EmailService:
    """Google SMTP를 통한 비동기 이메일 발송"""
    
    @staticmethod
    async def send_email(
        to_email: str,
        subject: str,
        body_html: str,
        body_text: str = None,
        retry_count: int = None
    ) -> bool:
        """
        비동기 이메일 발송 (재시도 로직 포함)
        
        Args:
            to_email: 수신자 이메일
            subject: 제목
            body_html: HTML 본문
            body_text: 텍스트 본문 (선택)
            retry_count: 재시도 횟수 (기본값: 환경변수)
        
        Returns:
            발송 성공 여부 (True/False)
        """
        if retry_count is None:
            retry_count = EMAIL_RETRY_COUNT
        
        for attempt in range(retry_count):
            try:
                # 메시지 구성
                msg = MIMEMultipart("alternative")
                msg["From"] = f"{EMAIL_FROM_NAME} <{EMAIL_FROM_ADDRESS}>"
                msg["To"] = to_email
                msg["Subject"] = subject
                msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
                
                # 텍스트 본문 추가 (메일 클라이언트가 HTML 미지원 시)
                if body_text:
                    msg.attach(MIMEText(body_text, "plain", "utf-8"))
                
                # HTML 본문 추가
                msg.attach(MIMEText(body_html, "html", "utf-8"))
                
                # 별도 스레드에서 SMTP 발송 (논블로킹)
                await asyncio.to_thread(
                    EmailService._send_smtp_blocking,
                    msg,
                    to_email
                )
                
                logger.info(f"✅ 이메일 발송 성공: {to_email} (제목: {subject})")
                return True
                
            except Exception as e:
                logger.warning(
                    f"⚠️ 이메일 발송 실패 (시도 {attempt+1}/{retry_count}): "
                    f"{to_email} - {str(e)}"
                )
                
                # 마지막 시도가 아니면 대기 후 재시도 (지수 백오프)
                if attempt < retry_count - 1:
                    delay = EMAIL_RETRY_DELAY_SECONDS * (2 ** attempt)
                    await asyncio.sleep(delay)
                continue
        
        logger.error(f"❌ 이메일 발송 최종 실패: {to_email} (제목: {subject})")
        return False
    
    @staticmethod
    def _send_smtp_blocking(msg, to_email):
        """
        동기 SMTP 발송 (asyncio.to_thread에서 실행)
        
        Args:
            msg: MIMEMultipart 메시지 객체
            to_email: 수신자 이메일
        """
        with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
            if SMTP_USE_TLS:
                server.starttls()
            
            server.login(SMTP_USERNAME, SMTP_PASSWORD)
            server.send_message(msg)

5.2 이메일 템플릿 모듈 (common_util_email_templates.py)

# common_util/common_util_email_templates.py

def get_otp_email_html(otp_code: str, valid_minutes: int = 5) -> tuple[str, str]:
    """
    OTP 인증 이메일 반환 (HTML + Text)
    
    Returns:
        (html_body, text_body)
    """
    html_body = f"""
    <html>
    <head>
        <meta charset="utf-8">
        <style>
            body {{
                font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
                color: #333;
                background-color: #f5f5f5;
            }}
            .container {{
                max-width: 600px;
                margin: 0 auto;
                padding: 20px;
                background-color: white;
                border-radius: 8px;
                box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            }}
            .header {{
                background: linear-gradient(135deg, #2d5016 0%, #4a7c2c 100%);
                color: white;
                padding: 30px;
                text-align: center;
                border-radius: 8px 8px 0 0;
            }}
            .header h1 {{
                margin: 0;
                font-size: 28px;
            }}
            .content {{
                padding: 30px;
            }}
            .otp-box {{
                background: #f9f9f9;
                border: 2px solid #2d5016;
                border-radius: 8px;
                padding: 20px;
                text-align: center;
                margin: 20px 0;
            }}
            .otp-code {{
                font-size: 42px;
                font-weight: bold;
                color: #2d5016;
                letter-spacing: 8px;
                font-family: 'Courier New', monospace;
            }}
            .warning {{
                background: #fff3cd;
                border-left: 4px solid #ffc107;
                padding: 12px;
                margin: 15px 0;
                border-radius: 4px;
                font-size: 14px;
            }}
            .footer {{
                font-size: 12px;
                color: #999;
                text-align: center;
                margin-top: 30px;
                border-top: 1px solid #eee;
                padding-top: 15px;
            }}
            .link {{
                color: #2d5016;
                text-decoration: none;
            }}
        </style>
    </head>
    <body>
        <div class="container">
            <div class="header">
                <h1>🌲 Aislo</h1>
                <p style="margin: 10px 0 0 0;">이메일 인증</p>
            </div>
            <div class="content">
                <p>안녕하세요!</p>
                <p>아래 인증 코드를 입력하여 계정을 확인해주세요:</p>
                
                <div class="otp-box">
                    <div class="otp-code">{otp_code}</div>
                </div>
                
                <p style="text-align: center; color: #666;">
                    <strong>이 코드는 {valid_minutes}분간 유효합니다.</strong>
                </p>
                
                <div class="warning">
                    <strong>⚠️ 주의:</strong> 본인이 요청하지 않았다면 이 이메일을 무시해주세요.
                </div>
                
                <p style="font-size: 14px; color: #666;">
                    Aislo는 절대로 귀하의 인증 코드를 요청하지 않습니다.
                </p>
            </div>
            <div class="footer">
                <p>&copy; 2026 Aislo. All rights reserved.</p>
                <p><a href="https://aislo.company" class="link">aislo.company</a></p>
            </div>
        </div>
    </body>
    </html>
    """
    
    text_body = f"""
Aislo - 이메일 인증

안녕하세요!

아래 인증 코드를 입력하여 계정을 확인해주세요:

    {otp_code}

이 코드는 {valid_minutes}분간 유효합니다.

주의: 본인이 요청하지 않았다면 이 이메일을 무시해주세요.

---
Aislo Support
https://aislo.company
    """
    
    return html_body, text_body


def get_join_approval_request_email_html(
    user_email: str,
    user_name: str,
    company_name: str,
    approval_url: str
) -> tuple[str, str]:
    """
    팀원 가입 승인 요청 이메일 (마스터 수신)
    """
    html_body = f"""
    <html>
    <head>
        <meta charset="utf-8">
        <style>
            body {{ font-family: 'Segoe UI', sans-serif; color: #333; }}
            .container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
            .header {{ background: #2d5016; color: white; padding: 20px; border-radius: 8px 8px 0 0; text-align: center; }}
            .content {{ background: white; padding: 20px; border: 1px solid #ddd; border-radius: 0 0 8px 8px; }}
            .info-box {{ background: #f0f8f0; border-left: 4px solid #2d5016; padding: 15px; margin: 20px 0; }}
            .button {{ background: #2d5016; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 15px 5px 15px 0; }}
            .footer {{ font-size: 12px; color: #999; margin-top: 20px; }}
        </style>
    </head>
    <body>
        <div class="container">
            <div class="header">
                <h2>새로운 팀원 가입 신청</h2>
            </div>
            <div class="content">
                <p>안녕하세요 관리자님,</p>
                
                <p><strong>{user_email}</strong>이 <strong>{company_name}</strong>에 가입을 신청했습니다.</p>
                
                <div class="info-box">
                    <strong>신청자 정보:</strong><br>
                    이메일: {user_email}<br>
                    이름: {user_name}<br>
                    회사: {company_name}
                </div>
                
                <p>아래 버튼을 클릭하여 승인 또는 거부해주세요:</p>
                
                <a href="{approval_url}" class="button">승인/거부 하기</a>
                
                <p style="font-size: 12px; color: #999;">
                    버튼이 작동하지 않으면 아래 링크를 직접 열어주세요:<br>
                    {approval_url}
                </p>
            </div>
            <div class="footer">
                <p>&copy; 2026 Aislo</p>
            </div>
        </div>
    </body>
    </html>
    """
    
    text_body = f"""
새로운 팀원 가입 신청

안녕하세요 관리자님,

{user_email}{company_name}에 가입을 신청했습니다.

신청자 정보:
- 이메일: {user_email}
- 이름: {user_name}
- 회사: {company_name}

아래 링크에서 승인 또는 거부해주세요:
{approval_url}

---
Aislo Support
    """
    
    return html_body, text_body

6. 라우터 통합 예시

6.1 회원가입 라우터 (A07_Register_Router.py)

# A07_Register/A07_Register_Router.py

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr
from common_util.common_util_email import EmailService
from common_util.common_util_email_templates import get_otp_email_html
import asyncio
import secrets

router = APIRouter()

class RegisterRequest(BaseModel):
    email: EmailStr
    password: str
    full_name: str

@router.post("/api/auth/register")
async def register(req: RegisterRequest):
    """회원가입 엔드포인트"""
    
    # 1. 검증: 이미 가입된 사용자인지 확인
    existing_user = await db.get_user_by_email(req.email)
    if existing_user:
        raise HTTPException(status_code=400, detail="이미 가입된 이메일입니다.")
    
    # 2. OTP 코드 생성 (6자리 난수)
    otp_code = str(secrets.randbelow(1000000)).zfill(6)
    
    # 3. DB에 임시 사용자 생성 (status='PENDING', otp_hash 저장)
    otp_hash = hash_password(otp_code)  # bcrypt 사용
    user_id = await db.create_user(
        email=req.email,
        password_hash=hash_password(req.password),
        full_name=req.full_name,
        status="PENDING",
        otp_hash=otp_hash,
        otp_expires_at=datetime.utcnow() + timedelta(minutes=5)
    )
    
    # 4. 백그라운드 태스크로 이메일 발송 (논블로킹)
    html_body, text_body = get_otp_email_html(otp_code)
    asyncio.create_task(
        EmailService.send_email(
            to_email=req.email,
            subject="[Aislo] 이메일 인증 코드",
            body_html=html_body,
            body_text=text_body
        )
    )
    
    # 5. 즉시 응답 (이메일 발송은 백그라운드에서 계속)
    return {
        "status": "success",
        "message": "인증 코드를 이메일로 발송했습니다. 5분 이내에 입력해주세요.",
        "user_id": user_id
    }

@router.post("/api/auth/verify-otp")
async def verify_otp(user_id: int, otp_code: str):
    """OTP 검증"""
    
    user = await db.get_user_by_id(user_id)
    
    # OTP 만료 확인
    if datetime.utcnow() > user.otp_expires_at:
        raise HTTPException(status_code=400, detail="인증 코드가 만료되었습니다.")
    
    # OTP 일치 확인 (hash 비교)
    if not verify_password(otp_code, user.otp_hash):
        raise HTTPException(status_code=400, detail="인증 코드가 일치하지 않습니다.")
    
    # 사용자 상태 업데이트
    await db.update_user(user_id, {
        "status": "ACTIVE",
        "otp_hash": None,
        "otp_expires_at": None
    })
    
    return {
        "status": "success",
        "message": "이메일 인증 완료되었습니다."
    }

7. 모니터링 및 로깅

7.1 이메일 발송 이력 저장 (선택)

향후 추가할 선택사항:

# DB 테이블
CREATE TABLE email_logs (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT,
  to_email VARCHAR(255),
  subject VARCHAR(255),
  email_type ENUM('OTP', 'JOIN_APPROVAL', 'RESULT', 'OTHER'),
  status ENUM('SUCCESS', 'FAILED'),
  sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  retry_count INT DEFAULT 0,
  error_message TEXT,
  FOREIGN KEY (user_id) REFERENCES users(id),
  INDEX (sent_at)
);

7.2 로깅 설정 (logging)

# config/config_system.py에 추가

import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('logs/email.log'),
        logging.StreamHandler()
    ]
)

8. Phase 2 마이그레이션 계획 (AWS SES)

8.1 AWS SES로 전환 시 변경 사항

# 미래: AWS SES로 변경 (최소 코드 수정)
SMTP_PROVIDER = "aws_ses"  # config에서만 변경
SMTP_HOST = "email-smtp.ap-northeast-2.amazonaws.com"
SMTP_USERNAME = "AKIA..."  # AWS Access Key
SMTP_PASSWORD = "..."  # AWS Secret Key

장점:

  • 높은 배송 성공률 (99%)
  • 빠른 발송 속도
  • 대량 발송 지원
  • 스팸 필터 회피 능력

9. 구현 체크리스트 (Phase 1)

필수 항목

  • .env 파일 작성 (Google 앱 비밀번호)
  • config_system.py 이메일 설정 추가
  • common_util_email.py 구현
  • common_util_email_templates.py 구현
  • A07_Register_Router.py 수정 (이메일 발송 통합)
  • 테스트: 실제 Gmail 발송 확인
  • logging 설정 추가
  • .gitignore에 .env 추가

선택 항목 (Phase 2)

  • email_logs DB 테이블 생성
  • 발송 이력 저장 로직
  • AWS SES 마이그레이션 준비
  • 이메일 템플릿 관리 시스템

10. 테스트 방법

10.1 개발 단계 테스트

# 1. .env 파일 작성 확인
cat config/.env | grep SMTP

# 2. Python 테스트 스크립트 실행
python -m pytest tests/test_email_service.py

# 3. Gmail 계정에서 발송 이력 확인
# Gmail 웹 → 설정 → 계정 및 가져오기 → 다른 이메일 주소에서 메일 보내기

10.2 이메일 도착 확인

발송된 이메일:
- 받는사람: [테스트 이메일]
- 제목: "[Aislo] 이메일 인증 코드"
- 본문: HTML 템플릿 포함, 6자리 OTP 코드
- 발신: "Aislo Support <umsangdon@gmail.com>"

11. 보안 주의사항

11.1 .env 파일 보안

# .gitignore에 추가 (필수)
config/.env
config/.env.local
.env.*

# Git에서 실수로 푸시된 경우
git rm --cached config/.env
git commit -m "Remove .env from git tracking"

11.2 Gmail 앱 비밀번호 회전

  • 매년 1회: 앱 비밀번호 재생성 권장
  • 의심 활동: 즉시 재생성
  • 개발 → 상용화: 새로운 비밀번호로 변경

11.3 SMTP 연결 암호화

  • TLS 사용 (포트 587)
  • SSL 포트 465는 피할 것 (deprecated)

12. 다음 단계

  1. 이 계획서 검토 및 피드백
  2. 📝 .env 파일 작성 + Google 앱 비밀번호 생성
  3. 💻 common_util_email.py 코드 작성
  4. 🧪 실제 Gmail 발송 테스트
  5. 📋 backend.md 업데이트 (이메일 API 섹션 추가)

작성일: 2026-07-07
담당자: 시스템 설계 팀
상태: 초안 (v1.0)