# 이메일 발송 정책 (멀티테넌시) > **작성일**: 2026-03-11 > **상태**: 설계 확정 --- ## 1. 개요 ### 1.1 목적 SAM 멀티테넌시 환경에서 이메일 발송의 테넌트 격리, 브랜딩, 발송 추적, 쿼터 관리를 위한 표준 정책을 정의한다. ### 1.2 핵심 원칙 | 원칙 | 설명 | |------|------| | 🔴 테넌트 격리 | 테넌트 A의 메일 설정/발송 기록이 테넌트 B에 노출되지 않는다 | | 🔴 중앙 서비스 경유 | 모든 메일 발송은 `TenantMailService`를 경유한다 | | 🟡 테넌트 브랜딩 | 발신자명, 로고, 서명을 테넌트별로 커스터마이징한다 | | 🟡 발송 기록 | 모든 발송은 `mail_logs` 테이블에 기록한다 | | 🟢 쿼터 관리 | 테넌트별 일일 발송 한도를 관리한다 | ### 1.3 현재 상태 (AS-IS) ``` 문제점: ├── 단일 SMTP (.env 고정) → Gmail 일일 500건 제한에 전체 영향 ├── 단일 발신 주소 (develop@codebridge-x.com) → 테넌트 구분 불가 ├── EsignRequestMail 중복 (API + MNG 양쪽에 존재) ├── 발송 기록 없음 → 추적/감사 불가 ├── Mail::to() 직접 호출 → 테넌트 설정 적용 불가 └── 템플릿 하드코딩 → 테넌트별 브랜딩 불가 ``` --- ## 2. 아키텍처 ### 2.1 3-Layer 구조 ``` ┌─────────────────────────────────────────────────────────┐ │ Layer 1: 테넌트 메일 설정 (tenant_mail_configs) │ │ SMTP 설정, 발신자 주소, 브랜딩 정보 │ ├─────────────────────────────────────────────────────────┤ │ Layer 2: 메일 발송 서비스 (TenantMailService) │ │ 테넌트 설정 자동 적용, 큐 발송, Fallback │ ├─────────────────────────────────────────────────────────┤ │ Layer 3: 발송 기록 (mail_logs) │ │ 발송 이력, 상태 추적, 일일 쿼터 관리 │ └─────────────────────────────────────────────────────────┘ ``` ### 2.2 발송 흐름 ``` Controller / Service │ ▼ TenantMailService::send($mailable, $to, $tenantId?) │ ├── 1. 테넌트 설정 조회 (tenant_mail_configs) │ └── 미설정 시 플랫폼 기본 SMTP 사용 │ ├── 2. 쿼터 확인 (일일 발송 한도) │ └── 초과 시 예외 발생 + 관리자 알림 │ ├── 3. Mailer 동적 구성 │ ├── SMTP host/port/user/pass 설정 │ ├── from address/name 설정 │ └── 브랜딩 데이터 주입 │ ├── 4. 발송 모드 결정 │ ├── sync: OTP, 비밀번호 (시간 민감) │ └── queue: 나머지 전부 │ ├── 5. mail_logs 기록 (status: queued/sent) │ └── 6. Fallback (자체 SMTP 실패 시) └── 플랫폼 기본 SMTP로 재시도 ``` --- ## 3. 테이블 설계 ### 3.1 `tenant_mail_configs` (테넌트 메일 설정) > **마이그레이션 위치**: `/home/aweso/sam/api/database/migrations/` ```sql CREATE TABLE tenant_mail_configs ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT UNSIGNED NOT NULL, provider ENUM('platform', 'smtp', 'ses', 'mailgun') DEFAULT 'platform', from_address VARCHAR(255) NOT NULL COMMENT '발신 이메일', from_name VARCHAR(255) NOT NULL COMMENT '발신자명', reply_to VARCHAR(255) NULL COMMENT '회신 주소', is_verified BOOLEAN DEFAULT FALSE COMMENT '도메인 검증 여부', daily_limit INT UNSIGNED DEFAULT 500 COMMENT '일일 발송 한도', is_active BOOLEAN DEFAULT TRUE, options JSON NULL COMMENT 'SMTP 설정, 브랜딩 정보', created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL, UNIQUE KEY uq_tenant_mail_configs (tenant_id), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ) COMMENT '테넌트 메일 설정'; ``` **`options` JSON 구조**: ```json { "smtp": { "host": "smtp.example.com", "port": 587, "username": "user@example.com", "password": "", "encryption": "tls" }, "branding": { "logo_url": "/storage/tenants/1/logo.png", "primary_color": "#1a56db", "company_name": "테넌트 회사명", "company_address": "서울시 강남구...", "company_phone": "02-1234-5678", "footer_text": "본 메일은 SAM 시스템에서 발송되었습니다." }, "ses": { "key": "", "secret": "", "region": "ap-northeast-2" } } ``` > **보안**: `options.smtp.password`, `options.ses.key`, `options.ses.secret`은 `encrypt()` / `decrypt()`로 저장/조회한다. ### 3.2 `mail_logs` (발송 기록) ```sql CREATE TABLE mail_logs ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT UNSIGNED NOT NULL, mailable_type VARCHAR(100) NOT NULL COMMENT 'Mailable 클래스명', to_address VARCHAR(255) NOT NULL COMMENT '수신자', from_address VARCHAR(255) NOT NULL COMMENT '발신자', subject VARCHAR(500) NOT NULL COMMENT '제목', status ENUM('queued', 'sent', 'failed', 'bounced') DEFAULT 'queued', sent_at TIMESTAMP NULL COMMENT '발송 시각', options JSON NULL COMMENT '에러 메시지, 재시도 횟수, 관련 모델', created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, INDEX idx_mail_logs_tenant_status (tenant_id, status), INDEX idx_mail_logs_tenant_date (tenant_id, created_at), INDEX idx_mail_logs_mailable (tenant_id, mailable_type), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ) COMMENT '메일 발송 기록'; ``` **`options` JSON 구조**: ```json { "error_message": "Connection refused", "retry_count": 2, "related_model": "App\\Models\\ESign\\EsignContract", "related_id": 123, "provider_used": "smtp", "fallback_used": true } ``` > **개인정보 보호**: `mail_logs`에 메일 본문(body)은 저장하지 않는다. 메타데이터만 기록한다. --- ## 4. 서비스 설계 ### 4.1 `TenantMailService` > **위치**: `/home/aweso/sam/api/app/Services/Mail/TenantMailService.php` > MNG에서도 동일 패턴의 서비스를 생성한다. ```php class TenantMailService { /** * 테넌트 설정을 적용하여 메일 발송 * * @param Mailable $mailable 발송할 Mailable 인스턴스 * @param string|array $to 수신자 * @param int|null $tenantId 테넌트 ID (null이면 현재 테넌트) * @param bool $sync 즉시 발송 여부 (기본: false = queue) */ public function send( Mailable $mailable, string|array $to, ?int $tenantId = null, bool $sync = false ): MailLog; } ``` ### 4.2 발송 모드 기준 | 모드 | Mailable | 사유 | |------|----------|------| | **sync** (즉시) | `EsignOtpMail` | OTP 시간 제한 | | **sync** (즉시) | `UserPasswordMail` | 즉시 로그인 필요 | | **queue** (큐) | `EsignRequestMail` | 비동기 가능 | | **queue** (큐) | `EsignCompletedMail` | 비동기 가능 | | **queue** (큐) | `PayslipMail` | 대량 발송 가능 | ### 4.3 Fallback 전략 ``` 테넌트 자체 SMTP 발송 시도 │ ├── 성공 → mail_logs (status: sent) │ └── 실패 → 플랫폼 기본 SMTP로 재시도 │ ├── 성공 → mail_logs (status: sent, fallback_used: true) │ └── 실패 → mail_logs (status: failed) └── 3회까지 자동 재시도 (queue retry) ``` --- ## 5. 메일 타입 정리 ### 5.1 현재 Mailable 목록 | Mailable | 위치 | 트리거 | 비고 | |----------|------|--------|------| | `EsignRequestMail` | API + MNG (중복) | 서명 요청 | 🔴 중복 제거 필요 | | `EsignOtpMail` | MNG | OTP 인증 | | | `EsignCompletedMail` | MNG | 서명 완료 | | | `UserPasswordMail` | MNG | 계정 생성/비번 초기화 | | | `PayslipMail` | MNG | 급여명세서 | | ### 5.2 Mailable 위치 정리 방향 ``` ❌ 현재: API와 MNG에 중복 존재 ✅ 목표: 비즈니스 로직은 API, MNG는 관리자 트리거만 API (app/Mail/) ├── EsignRequestMail.php ← API에서 통합 관리 ├── EsignOtpMail.php ├── EsignCompletedMail.php ├── UserPasswordMail.php └── PayslipMail.php MNG → API의 Mailable을 HTTP API로 호출 또는 공유 패키지로 분리 ``` > **현실적 방향**: 현재 MNG에서 직접 DB 접근하므로, MNG의 Mailable을 유지하되 `TenantMailService` 경유로 통일한다. API의 중복 `EsignRequestMail`은 제거한다. --- ## 6. 템플릿 브랜딩 ### 6.1 공통 레이아웃 ``` ┌─ emails.layouts.tenant ──────────────────────────┐ │ │ │ ┌─ 헤더 ──────────────────────────────────────┐ │ │ │ [테넌트 로고] 테넌트명 │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ ┌─ 본문 ──────────────────────────────────────┐ │ │ │ @yield('content') │ │ │ │ (각 Mailable에서 제공) │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ ┌─ 푸터 ──────────────────────────────────────┐ │ │ │ {{ 회사명 }} | {{ 주소 }} | {{ 연락처 }} │ │ │ │ "SAM 시스템에서 발송된 메일입니다" │ │ │ └─────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────┘ ``` ### 6.2 브랜딩 요소 | 요소 | 저장 위치 | 기본값 | |------|----------|--------| | 로고 이미지 | `options.branding.logo_url` | SAM BI 로고 | | 회사명 | `options.branding.company_name` | (주)코드브릿지엑스 | | 주소 | `options.branding.company_address` | — | | 연락처 | `options.branding.company_phone` | — | | 테마 컬러 | `options.branding.primary_color` | `#1a56db` | | 푸터 문구 | `options.branding.footer_text` | "SAM 시스템에서 발송된 메일입니다" | --- ## 7. 쿼터 관리 ### 7.1 쿼터 정책 | 항목 | 기본값 | 설명 | |------|--------|------| | 일일 발송 한도 | 500건 | `tenant_mail_configs.daily_limit` | | 경고 임계치 | 80% (400건) | 도달 시 관리자 알림 | | 초과 시 동작 | 발송 차단 + 예외 | `MailQuotaExceededException` | ### 7.2 쿼터 확인 ```php // mail_logs에서 오늘 발송 건수 조회 $todayCount = MailLog::where('tenant_id', $tenantId) ->whereDate('created_at', today()) ->whereIn('status', ['queued', 'sent']) ->count(); if ($todayCount >= $config->daily_limit) { throw new MailQuotaExceededException($tenantId); } ``` --- ## 8. 보안 규칙 ### 8.1 필수 준수 사항 ``` ✅ SMTP 비밀번호는 encrypt()로 암호화 저장 ✅ mail_logs에 메일 본문 저장 금지 (메타데이터만) ✅ 급여명세서 등 민감 메일은 related_model/related_id만 기록 ✅ tenant_mail_configs 조회 시 TenantScope 자동 적용 ✅ API 응답에 SMTP 비밀번호 노출 금지 (hidden 처리) ``` ### 8.2 금지 사항 ``` ❌ Mail::to() 직접 호출 금지 → TenantMailService 사용 ❌ .env SMTP 설정에 운영 크리덴셜 하드코딩 금지 ❌ 타 테넌트 mail_logs 조회 금지 (TenantScope로 방지) ❌ 이메일 본문에 비밀번호 평문 포함 금지 (임시 비밀번호 제외) ``` --- ## 9. 구현 단계 | Phase | 범위 | 주요 작업 | 우선순위 | |-------|------|----------|---------| | **Phase 1** | 기반 구축 | `tenant_mail_configs` + `mail_logs` 마이그레이션, `TenantMailService` 생성, 모델 생성 | 🔴 필수 | | **Phase 2** | 기존 전환 | 현재 5개 Mailable을 `TenantMailService` 경유로 변경, API `EsignRequestMail` 중복 제거 | 🔴 필수 | | **Phase 3** | 브랜딩 | 공통 레이아웃 생성, 테넌트별 로고/컬러/서명 적용, MNG 관리 화면 | 🟡 중요 | | **Phase 4** | 고급 기능 | 실패 재시도, 바운스 처리, 발송 통계 대시보드, SES/Mailgun 연동 | 🟢 권장 | --- ## 10. SMTP 제공자 비교 | 제공자 | 일일 한도 | 비용 | 적합 시점 | |--------|---------|------|----------| | Gmail SMTP | 500건 | 무료 | 현재 (소규모) | | Amazon SES | 무제한 | $0.10/1,000건 | 테넌트 10개+ | | Mailgun | 5,000건/월 무료 | $0.80/1,000건 | 중규모 | | 자체 SMTP | 무제한 | 서버 비용 | 테넌트 자체 운영 | > **권장**: Phase 1~2는 Gmail SMTP 유지, Phase 4에서 Amazon SES 전환 검토 --- ## 11. 기존 코드 전환 가이드 ### 11.1 Before (현재) ```php // MNG 컨트롤러에서 직접 발송 Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer)); ``` ### 11.2 After (전환 후) ```php // TenantMailService 경유 app(TenantMailService::class)->send( mailable: new EsignRequestMail($contract, $signer), to: $signer->email, // tenantId는 자동 감지 (현재 세션 기반) ); ``` --- ## 관련 문서 - [테넌트 이메일 연동 가이드](../guides/tenant-email-integration-guide.md) — MNG에서 테넌트 메일 설정, SMTP 프리셋, 연결 테스트 - [테넌트 DB 구조](../../system/database/tenants.md) - [전자서명 기능](../../features/esign/README.md) - [급여관리 기능](../../features/finance/payroll.md) - [API 개발 규칙](api-rules.md) - [options JSON 컬럼 정책](options-column-policy.md) --- **최종 업데이트**: 2026-03-11