260706_1_계정, 로그인, 보안 초안 작성

This commit is contained in:
2026-07-07 19:17:13 +09:00
parent 404941c47a
commit f8633bb1fe
4696 changed files with 6110 additions and 642171 deletions
@@ -0,0 +1,781 @@
# 이메일 인프라 구현 계획서 (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 파일 작성
```bash
# 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 수정
```python
# 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 기본 구조
```python
# 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)
```python
# 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)
```python
# 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 이메일 발송 이력 저장 (선택)
향후 추가할 선택사항:
```python
# 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)
```python
# 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로 전환 시 변경 사항
```python
# 미래: 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 개발 단계 테스트
```bash
# 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 파일 보안
```bash
# .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)