Files
sam-docs/dev/standards/email-policy.md
김보곤 73d64d4b03 docs: [email] 테넌트 이메일 연동 가이드 추가
- 테넌트 메일 연동 기술문서 신규 작성 (SMTP 프리셋, MNG 관리 화면, 연결 테스트)
- 기존 email-policy.md에 연동 가이드 참조 추가
- INDEX.md에 이메일 연동 문서 등록
2026-03-12 12:19:28 +09:00

15 KiB

이메일 발송 정책 (멀티테넌시)

작성일: 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/

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 구조:

{
    "smtp": {
        "host": "smtp.example.com",
        "port": 587,
        "username": "user@example.com",
        "password": "<encrypted>",
        "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": "<encrypted>",
        "secret": "<encrypted>",
        "region": "ap-northeast-2"
    }
}

보안: options.smtp.password, options.ses.key, options.ses.secretencrypt() / decrypt()로 저장/조회한다.

3.2 mail_logs (발송 기록)

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 구조:

{
    "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에서도 동일 패턴의 서비스를 생성한다.

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 쿼터 확인

// 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 (현재)

// MNG 컨트롤러에서 직접 발송
Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer));

11.2 After (전환 후)

// TenantMailService 경유
app(TenantMailService::class)->send(
    mailable: new EsignRequestMail($contract, $signer),
    to: $signer->email,
    // tenantId는 자동 감지 (현재 세션 기반)
);

관련 문서


최종 업데이트: 2026-03-11