docs: [bending] 절곡품 전용 테이블 분리 완료 문서

- README: bending_items 266건 + bending_models 62건 DB 검증 완료
- README: 하장바 검색 문제 해결 (10건 정상)
- README: bending_data JSON 통합, bending_item_mappings DROP
- README: LOT 코드 체계, 테이블 관계도, 레거시 대응표 갱신
- step1: 데이터분석 업데이트
- step5: canvas 그리기 추가
- .gitattributes CRLF→LF 정규화
This commit is contained in:
강영보
2026-03-19 20:03:46 +09:00
parent 6484e73976
commit 220ab78041
41 changed files with 16081 additions and 14435 deletions

View File

@@ -1,412 +1,412 @@
# 이메일 발송 정책 (멀티테넌시)
> **작성일**: 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": "<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.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
# 이메일 발송 정책 (멀티테넌시)
> **작성일**: 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": "<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.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

View File

@@ -1,329 +1,329 @@
# DomPDF 사용 가이드
> **작성일**: 2026-03-11
> **패키지**: `barryvdh/laravel-dompdf` v3.1 (DomPDF v3.1.5)
> **구현 참조**: `mng/app/Services/HR/PayrollService.php`
---
## 1. DomPDF 인스턴스 규칙
### 1.1 폰트 등록은 반드시 렌더링할 인스턴스에
`Pdf::loadView()`는 **매번 새 DomPDF 인스턴스**를 생성한다. 다른 인스턴스에 폰트를 등록해도 렌더링 인스턴스에는 적용되지 않는다.
```php
// ❌ 인스턴스 불일치 — 폰트가 적용되지 않음
$dompdf = app(\Barryvdh\DomPDF\PDF::class)->getDomPDF(); // 인스턴스 A
$dompdf->getFontMetrics()->registerFont(...);
$pdf = Pdf::loadView('view', $data); // 인스턴스 B (폰트 없음)
$pdf->output();
// ✅ 동일 인스턴스에 등록
$pdf = Pdf::loadView('view', $data);
$dompdf = $pdf->getDomPDF(); // loadView가 만든 바로 그 인스턴스
$dompdf->getFontMetrics()->registerFont(...);
$pdf->output();
```
### 1.2 등록 → 렌더링 순서
```
Pdf::loadView() → registerFont() → saveFontFamilies() → $pdf->output()
```
`output()` 호출 시 내부에서 `render()`가 실행되므로, 그 전에 폰트 등록이 완료되어야 한다.
---
## 2. setOptions() 사용 금지
### 2.1 문제
`->setOptions([...])` 호출 시 DomPDF 내부에서 `new Options($options)`를 실행한다. 이때 **전달한 옵션만 설정되고 나머지는 DomPDF 기본값으로 초기화**된다.
```php
// ❌ chroot, font_dir 등 config/dompdf.php 설정이 모두 초기화됨
$pdf = Pdf::loadView('view', $data)
->setOptions([
'font_dir' => storage_path('fonts'),
'enable_font_subsetting' => true,
]);
// 이 시점에서 chroot = vendor/dompdf/dompdf (DomPDF 기본값)
```
### 2.2 해결
`config/dompdf.php`에 모든 설정을 선언하고, 코드에서 `setOptions()`를 호출하지 않는다.
```php
// config/dompdf.php — 여기에 모든 설정
'options' => [
'font_dir' => storage_path('fonts'),
'font_cache' => storage_path('fonts'),
'enable_font_subsetting' => true,
'chroot' => array_filter([
realpath(base_path()),
realpath(storage_path('fonts')),
]),
// ...
],
// ✅ 코드에서는 setOptions 없이 사용
$pdf = Pdf::loadView('view', $data)->setPaper('a4');
```
---
## 3. chroot와 파일 경로
### 3.1 chroot 검증 원리
DomPDF의 `validateLocalUri()`는 폰트 파일 접근 시 다음을 검사한다:
```
realpath(파일 경로)가 realpath(chroot) 하위인가?
```
**symlink는 realpath()로 해소**되므로, symlink 경로가 chroot 밖을 가리키면 차단된다.
### 3.2 릴리스 기반 배포 환경
```
mng/current → releases/20260311_134148/ (배포마다 변경)
releases/XXXXX/storage/fonts → ../../shared/storage/fonts/ (symlink)
```
- `storage_path('fonts')``/home/.../releases/XXXXX/storage/fonts` (symlink 경로)
- `realpath()``/home/.../shared/storage/fonts` (실제 경로)
- `base_path()``/home/.../releases/XXXXX/` (릴리스 경로)
**shared 경로는 릴리스 경로 하위가 아니므로** chroot에 별도 등록이 필요하다.
### 3.3 chroot 설정
```php
// config/dompdf.php
'chroot' => array_filter([
realpath(base_path()), // 릴리스 내부 파일 허용
realpath(storage_path('fonts')), // shared 폰트 디렉토리 허용
]),
```
`array_filter()``realpath()``false`를 반환할 경우(경로 미존재) 제거하기 위함이다.
---
## 4. 폰트 파일 경로 선택
### 4.1 resource_path() vs storage_path()
| 항목 | `resource_path()` | `storage_path()` |
|------|-------------------|-------------------|
| 릴리스 변경 시 | 경로 변경됨 | symlink → shared (불변) |
| .ufm 캐시 | 매 배포마다 재생성 | 유지됨 |
| installed-fonts.json | 경로 불일치로 무효화 | 안정적 |
| Git 포함 | O (원본 보관용) | X (.gitignore) |
**결론**: 폰트 등록 시 `storage_path()` 사용. 원본 TTF는 `resources/fonts/`에 Git으로 관리하고, 최초 실행 시 `storage/fonts/`로 복사한다.
### 4.2 폰트 복사 패턴
```php
$fontDir = storage_path('fonts');
$dst = $fontDir.'/Pretendard-Regular.ttf';
// 최초 1회만 복사 (이후 shared에 유지)
if (! file_exists($dst)) {
$src = resource_path('fonts/Pretendard-Regular.ttf');
if (! file_exists($src)) {
return;
}
copy($src, $dst);
}
```
---
## 5. 외부 폰트 및 금지 사항
### 5.1 구글 폰트 금지
```
❌ @import url('https://fonts.googleapis.com/...');
❌ <link href="https://fonts.googleapis.com/..." rel="stylesheet">
❌ @font-face src: url('https://...');
```
DomPDF는 웹 브라우저가 아니다. 외부 폰트 다운로드는 네트워크 의존성, 방화벽 차단, 성능 저하를 유발한다.
### 5.2 isRemoteEnabled 금지
```php
// ❌ 보안 위험 + 외부 의존성
->setOptions(['isRemoteEnabled' => true])
```
### 5.3 font-weight 800 이상 사용 금지
Pretendard는 `normal`(400)과 `bold`(700)만 DomPDF에 등록되어 있다. `font-weight: 800` 이상을 지정하면 DomPDF가 매칭되는 폰트를 찾지 못해 해당 텍스트의 한글이 `?`로 깨진다.
```css
/* ❌ DomPDF에서 한글 깨짐 — 800 weight에 매칭되는 폰트 없음 */
h1 { font-weight: 800; }
/* ✅ bold(700)까지만 사용 */
h1 { font-weight: bold; }
```
### 5.4 시스템 전용 폰트 단독 사용 금지
DomPDF는 OS 시스템 폰트를 자동 인식하지 않는다. `registerFont()`로 등록된 폰트만 사용 가능하다.
```css
/* ❌ DomPDF가 인식 못함 → 한글 ??? */
body { font-family: 'Malgun Gothic', sans-serif; }
/* ✅ DomPDF에 등록된 폰트 사용 */
body { font-family: 'Pretendard', 'Malgun Gothic', sans-serif; }
```
> `Malgun Gothic`은 브라우저에서 HTML을 직접 볼 때의 fallback 용도로만 기재한다.
---
## 6. PDF 경량화 설정
### 6.1 폰트 서브셋팅 (필수)
`config/dompdf.php`에서 `enable_font_subsetting``true`로 설정한다. PDF에 실제 사용된 문자의 글리프만 포함하여 용량을 대폭 줄인다.
```php
// config/dompdf.php
'options' => [
'enable_font_subsetting' => true, // ✅ 필수 — 사용 글자만 임베딩
'enable_javascript' => false, // ✅ 권장 — PDF 내 JS 불필요
// ...
],
```
| 설정 | 변경 전 | 변경 후 | 효과 |
|------|---------|---------|------|
| `enable_font_subsetting` | `false` | `true` | 폰트 전체(~2-5MB) → 사용 글자만(수십KB) |
| `enable_javascript` | `true` | `false` | PDF 내 JS 코드 제거 |
> 한글 폰트는 11,172개의 완성형 글자를 포함하지만, 급여명세서에 사용되는 글자는 100~200자 수준이다. 서브셋팅으로 99% 이상의 불필요한 글리프를 제거한다.
---
## 7. 표준 PDF 생성 패턴
### 7.1 전체 코드
```php
$pdf = Pdf::loadView('emails.payslip', ['payslipData' => $data])
->setPaper('a4');
$this->registerKoreanFont($pdf); // 동일 인스턴스에 등록
$pdfContent = $pdf->output();
```
### 7.2 registerKoreanFont 구현
```php
private function registerKoreanFont(\Barryvdh\DomPDF\PDF $pdf): void
{
$fontDir = storage_path('fonts');
$normalDst = $fontDir.'/Pretendard-Regular.ttf';
$boldDst = $fontDir.'/Pretendard-Bold.ttf';
// resources → storage 복사 (최초 1회, 이후 shared에 유지)
if (! file_exists($normalDst)) {
$src = resource_path('fonts/Pretendard-Regular.ttf');
if (! file_exists($src)) {
return;
}
if (! is_dir($fontDir)) {
mkdir($fontDir, 0755, true);
}
copy($src, $normalDst);
}
if (! file_exists($boldDst)) {
$src = resource_path('fonts/Pretendard-Bold.ttf');
if (file_exists($src)) {
copy($src, $boldDst);
}
}
$dompdf = $pdf->getDomPDF();
$fm = $dompdf->getFontMetrics();
$fm->registerFont(
['family' => 'pretendard', 'style' => 'normal', 'weight' => 'normal'],
$normalDst
);
if (file_exists($boldDst)) {
$fm->registerFont(
['family' => 'pretendard', 'style' => 'normal', 'weight' => 'bold'],
$boldDst
);
}
$fm->saveFontFamilies();
}
```
---
## 8. 폰트 캐시 구조
```
storage/fonts/ ← shared 디렉토리 (배포 간 유지)
├── installed-fonts.json ← DomPDF 폰트 레지스트리
├── Pretendard-Regular.ttf ← resources/에서 복사된 원본
├── Pretendard-Bold.ttf
├── pretendard_normal_*.ufm ← DomPDF 메트릭 캐시 (자동 생성)
├── pretendard_normal_*.ttf ← DomPDF 서브셋 (자동 생성)
└── pretendard_bold_*.*
```
> `storage/fonts/`는 `.gitignore`에 포함되어 있다. 각 환경에서 첫 PDF 생성 시 자동으로 생성된다.
---
## 9. 체크리스트
### PDF 뷰 작성 시
- [ ] 외부 폰트 URL 미포함 (`@import`, `<link>`)
- [ ] `font-family``Pretendard` 포함 (DomPDF 한글 지원)
- [ ] `font-family` fallback에 `Malgun Gothic`, `sans-serif` 포함 (브라우저용)
- [ ] `font-weight``normal`/`bold`만 사용 (800 이상 금지)
### PDF 생성 코드 작성 시
- [ ] `setOptions()` 미사용 (config/dompdf.php에 선언)
- [ ] `Pdf::loadView()` 후 **동일 인스턴스**에 폰트 등록
- [ ] 폰트 경로는 `storage_path()` 사용 (`resource_path()` 아님)
- [ ] `isRemoteEnabled` 미사용
### 배포 환경 확인
- [ ] `config/dompdf.php` chroot에 `realpath(storage_path('fonts'))` 포함
- [ ] `storage/fonts/` 디렉토리 쓰기 권한 확인
- [ ] 폰트 TTF 파일이 `resources/fonts/`에 Git 관리됨
- [ ] `enable_font_subsetting``true`
---
## 관련 문서
- DomPDF 설정: `mng/config/dompdf.php`
- 구현 참조: `mng/app/Services/HR/PayrollService.php``registerKoreanFont()`
- 폰트 원본: `mng/resources/fonts/` — Pretendard TTF (Git 관리)
- 서버 운영: `dev/deploys/ops-manual/README.md`
---
# DomPDF 사용 가이드
> **작성일**: 2026-03-11
> **패키지**: `barryvdh/laravel-dompdf` v3.1 (DomPDF v3.1.5)
> **구현 참조**: `mng/app/Services/HR/PayrollService.php`
---
## 1. DomPDF 인스턴스 규칙
### 1.1 폰트 등록은 반드시 렌더링할 인스턴스에
`Pdf::loadView()`는 **매번 새 DomPDF 인스턴스**를 생성한다. 다른 인스턴스에 폰트를 등록해도 렌더링 인스턴스에는 적용되지 않는다.
```php
// ❌ 인스턴스 불일치 — 폰트가 적용되지 않음
$dompdf = app(\Barryvdh\DomPDF\PDF::class)->getDomPDF(); // 인스턴스 A
$dompdf->getFontMetrics()->registerFont(...);
$pdf = Pdf::loadView('view', $data); // 인스턴스 B (폰트 없음)
$pdf->output();
// ✅ 동일 인스턴스에 등록
$pdf = Pdf::loadView('view', $data);
$dompdf = $pdf->getDomPDF(); // loadView가 만든 바로 그 인스턴스
$dompdf->getFontMetrics()->registerFont(...);
$pdf->output();
```
### 1.2 등록 → 렌더링 순서
```
Pdf::loadView() → registerFont() → saveFontFamilies() → $pdf->output()
```
`output()` 호출 시 내부에서 `render()`가 실행되므로, 그 전에 폰트 등록이 완료되어야 한다.
---
## 2. setOptions() 사용 금지
### 2.1 문제
`->setOptions([...])` 호출 시 DomPDF 내부에서 `new Options($options)`를 실행한다. 이때 **전달한 옵션만 설정되고 나머지는 DomPDF 기본값으로 초기화**된다.
```php
// ❌ chroot, font_dir 등 config/dompdf.php 설정이 모두 초기화됨
$pdf = Pdf::loadView('view', $data)
->setOptions([
'font_dir' => storage_path('fonts'),
'enable_font_subsetting' => true,
]);
// 이 시점에서 chroot = vendor/dompdf/dompdf (DomPDF 기본값)
```
### 2.2 해결
`config/dompdf.php`에 모든 설정을 선언하고, 코드에서 `setOptions()`를 호출하지 않는다.
```php
// config/dompdf.php — 여기에 모든 설정
'options' => [
'font_dir' => storage_path('fonts'),
'font_cache' => storage_path('fonts'),
'enable_font_subsetting' => true,
'chroot' => array_filter([
realpath(base_path()),
realpath(storage_path('fonts')),
]),
// ...
],
// ✅ 코드에서는 setOptions 없이 사용
$pdf = Pdf::loadView('view', $data)->setPaper('a4');
```
---
## 3. chroot와 파일 경로
### 3.1 chroot 검증 원리
DomPDF의 `validateLocalUri()`는 폰트 파일 접근 시 다음을 검사한다:
```
realpath(파일 경로)가 realpath(chroot) 하위인가?
```
**symlink는 realpath()로 해소**되므로, symlink 경로가 chroot 밖을 가리키면 차단된다.
### 3.2 릴리스 기반 배포 환경
```
mng/current → releases/20260311_134148/ (배포마다 변경)
releases/XXXXX/storage/fonts → ../../shared/storage/fonts/ (symlink)
```
- `storage_path('fonts')``/home/.../releases/XXXXX/storage/fonts` (symlink 경로)
- `realpath()``/home/.../shared/storage/fonts` (실제 경로)
- `base_path()``/home/.../releases/XXXXX/` (릴리스 경로)
**shared 경로는 릴리스 경로 하위가 아니므로** chroot에 별도 등록이 필요하다.
### 3.3 chroot 설정
```php
// config/dompdf.php
'chroot' => array_filter([
realpath(base_path()), // 릴리스 내부 파일 허용
realpath(storage_path('fonts')), // shared 폰트 디렉토리 허용
]),
```
`array_filter()``realpath()``false`를 반환할 경우(경로 미존재) 제거하기 위함이다.
---
## 4. 폰트 파일 경로 선택
### 4.1 resource_path() vs storage_path()
| 항목 | `resource_path()` | `storage_path()` |
|------|-------------------|-------------------|
| 릴리스 변경 시 | 경로 변경됨 | symlink → shared (불변) |
| .ufm 캐시 | 매 배포마다 재생성 | 유지됨 |
| installed-fonts.json | 경로 불일치로 무효화 | 안정적 |
| Git 포함 | O (원본 보관용) | X (.gitignore) |
**결론**: 폰트 등록 시 `storage_path()` 사용. 원본 TTF는 `resources/fonts/`에 Git으로 관리하고, 최초 실행 시 `storage/fonts/`로 복사한다.
### 4.2 폰트 복사 패턴
```php
$fontDir = storage_path('fonts');
$dst = $fontDir.'/Pretendard-Regular.ttf';
// 최초 1회만 복사 (이후 shared에 유지)
if (! file_exists($dst)) {
$src = resource_path('fonts/Pretendard-Regular.ttf');
if (! file_exists($src)) {
return;
}
copy($src, $dst);
}
```
---
## 5. 외부 폰트 및 금지 사항
### 5.1 구글 폰트 금지
```
❌ @import url('https://fonts.googleapis.com/...');
❌ <link href="https://fonts.googleapis.com/..." rel="stylesheet">
❌ @font-face src: url('https://...');
```
DomPDF는 웹 브라우저가 아니다. 외부 폰트 다운로드는 네트워크 의존성, 방화벽 차단, 성능 저하를 유발한다.
### 5.2 isRemoteEnabled 금지
```php
// ❌ 보안 위험 + 외부 의존성
->setOptions(['isRemoteEnabled' => true])
```
### 5.3 font-weight 800 이상 사용 금지
Pretendard는 `normal`(400)과 `bold`(700)만 DomPDF에 등록되어 있다. `font-weight: 800` 이상을 지정하면 DomPDF가 매칭되는 폰트를 찾지 못해 해당 텍스트의 한글이 `?`로 깨진다.
```css
/* ❌ DomPDF에서 한글 깨짐 — 800 weight에 매칭되는 폰트 없음 */
h1 { font-weight: 800; }
/* ✅ bold(700)까지만 사용 */
h1 { font-weight: bold; }
```
### 5.4 시스템 전용 폰트 단독 사용 금지
DomPDF는 OS 시스템 폰트를 자동 인식하지 않는다. `registerFont()`로 등록된 폰트만 사용 가능하다.
```css
/* ❌ DomPDF가 인식 못함 → 한글 ??? */
body { font-family: 'Malgun Gothic', sans-serif; }
/* ✅ DomPDF에 등록된 폰트 사용 */
body { font-family: 'Pretendard', 'Malgun Gothic', sans-serif; }
```
> `Malgun Gothic`은 브라우저에서 HTML을 직접 볼 때의 fallback 용도로만 기재한다.
---
## 6. PDF 경량화 설정
### 6.1 폰트 서브셋팅 (필수)
`config/dompdf.php`에서 `enable_font_subsetting``true`로 설정한다. PDF에 실제 사용된 문자의 글리프만 포함하여 용량을 대폭 줄인다.
```php
// config/dompdf.php
'options' => [
'enable_font_subsetting' => true, // ✅ 필수 — 사용 글자만 임베딩
'enable_javascript' => false, // ✅ 권장 — PDF 내 JS 불필요
// ...
],
```
| 설정 | 변경 전 | 변경 후 | 효과 |
|------|---------|---------|------|
| `enable_font_subsetting` | `false` | `true` | 폰트 전체(~2-5MB) → 사용 글자만(수십KB) |
| `enable_javascript` | `true` | `false` | PDF 내 JS 코드 제거 |
> 한글 폰트는 11,172개의 완성형 글자를 포함하지만, 급여명세서에 사용되는 글자는 100~200자 수준이다. 서브셋팅으로 99% 이상의 불필요한 글리프를 제거한다.
---
## 7. 표준 PDF 생성 패턴
### 7.1 전체 코드
```php
$pdf = Pdf::loadView('emails.payslip', ['payslipData' => $data])
->setPaper('a4');
$this->registerKoreanFont($pdf); // 동일 인스턴스에 등록
$pdfContent = $pdf->output();
```
### 7.2 registerKoreanFont 구현
```php
private function registerKoreanFont(\Barryvdh\DomPDF\PDF $pdf): void
{
$fontDir = storage_path('fonts');
$normalDst = $fontDir.'/Pretendard-Regular.ttf';
$boldDst = $fontDir.'/Pretendard-Bold.ttf';
// resources → storage 복사 (최초 1회, 이후 shared에 유지)
if (! file_exists($normalDst)) {
$src = resource_path('fonts/Pretendard-Regular.ttf');
if (! file_exists($src)) {
return;
}
if (! is_dir($fontDir)) {
mkdir($fontDir, 0755, true);
}
copy($src, $normalDst);
}
if (! file_exists($boldDst)) {
$src = resource_path('fonts/Pretendard-Bold.ttf');
if (file_exists($src)) {
copy($src, $boldDst);
}
}
$dompdf = $pdf->getDomPDF();
$fm = $dompdf->getFontMetrics();
$fm->registerFont(
['family' => 'pretendard', 'style' => 'normal', 'weight' => 'normal'],
$normalDst
);
if (file_exists($boldDst)) {
$fm->registerFont(
['family' => 'pretendard', 'style' => 'normal', 'weight' => 'bold'],
$boldDst
);
}
$fm->saveFontFamilies();
}
```
---
## 8. 폰트 캐시 구조
```
storage/fonts/ ← shared 디렉토리 (배포 간 유지)
├── installed-fonts.json ← DomPDF 폰트 레지스트리
├── Pretendard-Regular.ttf ← resources/에서 복사된 원본
├── Pretendard-Bold.ttf
├── pretendard_normal_*.ufm ← DomPDF 메트릭 캐시 (자동 생성)
├── pretendard_normal_*.ttf ← DomPDF 서브셋 (자동 생성)
└── pretendard_bold_*.*
```
> `storage/fonts/`는 `.gitignore`에 포함되어 있다. 각 환경에서 첫 PDF 생성 시 자동으로 생성된다.
---
## 9. 체크리스트
### PDF 뷰 작성 시
- [ ] 외부 폰트 URL 미포함 (`@import`, `<link>`)
- [ ] `font-family``Pretendard` 포함 (DomPDF 한글 지원)
- [ ] `font-family` fallback에 `Malgun Gothic`, `sans-serif` 포함 (브라우저용)
- [ ] `font-weight``normal`/`bold`만 사용 (800 이상 금지)
### PDF 생성 코드 작성 시
- [ ] `setOptions()` 미사용 (config/dompdf.php에 선언)
- [ ] `Pdf::loadView()` 후 **동일 인스턴스**에 폰트 등록
- [ ] 폰트 경로는 `storage_path()` 사용 (`resource_path()` 아님)
- [ ] `isRemoteEnabled` 미사용
### 배포 환경 확인
- [ ] `config/dompdf.php` chroot에 `realpath(storage_path('fonts'))` 포함
- [ ] `storage/fonts/` 디렉토리 쓰기 권한 확인
- [ ] 폰트 TTF 파일이 `resources/fonts/`에 Git 관리됨
- [ ] `enable_font_subsetting``true`
---
## 관련 문서
- DomPDF 설정: `mng/config/dompdf.php`
- 구현 참조: `mng/app/Services/HR/PayrollService.php``registerKoreanFont()`
- 폰트 원본: `mng/resources/fonts/` — Pretendard TTF (Git 관리)
- 서버 운영: `dev/deploys/ops-manual/README.md`
---
**최종 업데이트**: 2026-03-11