From cb77190cd6c74b278573f3bedbdcaab130df0e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 11 Mar 2026 14:00:27 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20[standards]=20DomPDF=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EC=9E=AC=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인스턴스 규칙, setOptions 금지, chroot/symlink 주의사항 추가 - font-weight 800 금지, 서브셋팅 경량화 섹션 병합 - 배포 환경(릴리스/shared) 대응 가이드 포함 --- dev/standards/pdf-font-policy.md | 472 ++++++++++++++----------------- 1 file changed, 220 insertions(+), 252 deletions(-) diff --git a/dev/standards/pdf-font-policy.md b/dev/standards/pdf-font-policy.md index 3ead7cb..f1f4524 100644 --- a/dev/standards/pdf-font-policy.md +++ b/dev/standards/pdf-font-policy.md @@ -1,44 +1,154 @@ -# PDF 생성 시 폰트 정책 +# DomPDF 사용 가이드 > **작성일**: 2026-03-11 -> **상태**: 설계 확정 +> **패키지**: `barryvdh/laravel-dompdf` v3.1 (DomPDF v3.1.5) +> **구현 참조**: `mng/app/Services/HR/PayrollService.php` --- -## 1. 개요 +## 1. DomPDF 인스턴스 규칙 -### 1.1 목적 +### 1.1 폰트 등록은 반드시 렌더링할 인스턴스에 -DomPDF로 PDF를 생성할 때 폰트 관련 문제(권한 오류, 외부 의존성, 배포 시 재발, 한글 깨짐)를 방지하기 위한 정책이다. +`Pdf::loadView()`는 **매번 새 DomPDF 인스턴스**를 생성한다. 다른 인스턴스에 폰트를 등록해도 렌더링 인스턴스에는 적용되지 않는다. -### 1.2 배경 — 운영서버 장애 사례 +```php +// ❌ 인스턴스 불일치 — 폰트가 적용되지 않음 +$dompdf = app(\Barryvdh\DomPDF\PDF::class)->getDomPDF(); // 인스턴스 A +$dompdf->getFontMetrics()->registerFont(...); -급여명세서 PDF 생성 시 500 에러 발생. 원인은 DomPDF가 구글 폰트(Noto Sans KR)를 외부에서 다운로드한 후 캐시 파일(`.ufm`)을 `vendor/dompdf/dompdf/lib/fonts/`에 저장하려는데, `www-data` 유저에 쓰기 권한이 없어서 Permission denied 발생. +$pdf = Pdf::loadView('view', $data); // 인스턴스 B (폰트 없음) +$pdf->output(); -``` -fopen(.../vendor/dompdf/dompdf/lib/fonts/noto_sans_kr_normal_...ufm): -Failed to open stream: Permission denied +// ✅ 동일 인스턴스에 등록 +$pdf = Pdf::loadView('view', $data); +$dompdf = $pdf->getDomPDF(); // loadView가 만든 바로 그 인스턴스 +$dompdf->getFontMetrics()->registerFont(...); +$pdf->output(); ``` -### 1.3 핵심 원칙 +### 1.2 등록 → 렌더링 순서 ``` -❌ PDF 뷰에서 구글 폰트(@import, ) 사용 금지 -❌ DomPDF의 isRemoteEnabled 옵션 사용 금지 -❌ font-family에 'Malgun Gothic' 등 시스템 전용 폰트 단독 사용 금지 (DomPDF가 인식 못함) -❌ font-weight: 800 이상 사용 금지 (DomPDF 미매칭으로 한글 깨짐) -✅ Pretendard를 DomPDF에 등록하여 사용 -✅ 폰트 원본은 resources/fonts/에 프로젝트와 함께 배포 (시스템 설치 의존 금지) -✅ 폰트 캐시는 storage/fonts/에 저장 (vendor/ 아님) -✅ 자동 등록 패턴(ensureKoreanFont) 적용 -✅ 폰트 서브셋팅 활성화 (config/dompdf.php) +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'); ``` --- -## 2. 금지 사항 +## 3. chroot와 파일 경로 -### 2.1 구글 폰트 외부 로드 금지 +### 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/...'); @@ -46,15 +156,16 @@ Failed to open stream: Permission denied ❌ @font-face src: url('https://...'); ``` -### 2.2 isRemoteEnabled 옵션 금지 +DomPDF는 웹 브라우저가 아니다. 외부 폰트 다운로드는 네트워크 의존성, 방화벽 차단, 성능 저하를 유발한다. + +### 5.2 isRemoteEnabled 금지 ```php -// ❌ 금지 — 외부 리소스 다운로드를 활성화하면 안 됨 -$pdf = Pdf::loadView('view', $data) - ->setOptions(['isRemoteEnabled' => true]); +// ❌ 보안 위험 + 외부 의존성 +->setOptions(['isRemoteEnabled' => true]) ``` -### 2.3 font-weight 800 이상 사용 금지 +### 5.3 font-weight 800 이상 사용 금지 Pretendard는 `normal`(400)과 `bold`(700)만 DomPDF에 등록되어 있다. `font-weight: 800` 이상을 지정하면 DomPDF가 매칭되는 폰트를 찾지 못해 해당 텍스트의 한글이 `?`로 깨진다. @@ -66,167 +177,25 @@ h1 { font-weight: 800; } h1 { font-weight: bold; } ``` -### 2.4 DomPDF 미등록 폰트 사용 금지 +### 5.4 시스템 전용 폰트 단독 사용 금지 -DomPDF는 시스템 폰트를 자동으로 인식하지 않는다. `Malgun Gothic`, `Apple SD Gothic Neo` 등을 `font-family`에 지정해도 DomPDF가 찾지 못해 한글이 `???`로 깨진다. +DomPDF는 OS 시스템 폰트를 자동 인식하지 않는다. `registerFont()`로 등록된 폰트만 사용 가능하다. ```css -/* ❌ DomPDF에서 한글 깨짐 */ +/* ❌ DomPDF가 인식 못함 → 한글 ??? */ body { font-family: 'Malgun Gothic', sans-serif; } + +/* ✅ DomPDF에 등록된 폰트 사용 */ +body { font-family: 'Pretendard', 'Malgun Gothic', sans-serif; } ``` +> `Malgun Gothic`은 브라우저에서 HTML을 직접 볼 때의 fallback 용도로만 기재한다. + --- -## 3. 금지 이유 +## 6. PDF 경량화 설정 -### 3.1 배포 시 권한 문제 재발 - -`composer install`이 `vendor/`를 새로 생성하면 폰트 캐시 디렉토리 권한이 초기화된다. `vendor/` 내부 특정 경로에 권한을 거는 것은 안티패턴이다. - -``` -배포 → composer install → vendor/ 재생성 → 폰트 캐시 권한 초기화 → PDF 생성 실패 -``` - -### 3.2 외부 네트워크 의존성 - -PDF를 생성할 때마다 `fonts.googleapis.com`에 요청한다. 외부 서버 장애, 네트워크 지연, 방화벽 차단 시 PDF 생성이 실패한다. 내부 ERP에서 외부 서비스에 의존할 이유가 없다. - -### 3.3 PDF에서 웹폰트 불필요 - -DomPDF는 웹 브라우저가 아니다. 구글 폰트를 다운로드 → 파싱 → 캐싱하는 과정이 불필요한 오버헤드다. - ---- - -## 4. 올바른 폰트 사용법 - -### 4.1 표준 한글 폰트: Pretendard - -SAM 프로젝트의 PDF 한글 폰트는 **Pretendard**를 사용한다. 폰트 파일은 프로젝트에 포함되어 Git으로 배포된다. 서버에 별도 폰트 패키지를 설치할 필요가 없다. - -| 항목 | 경로 | 설명 | -|------|------|------| -| 폰트 원본 (Git 관리) | `resources/fonts/Pretendard-Regular.ttf` | 프로젝트와 함께 배포 | -| 폰트 원본 (Git 관리) | `resources/fonts/Pretendard-Bold.ttf` | 프로젝트와 함께 배포 | -| DomPDF 캐시 | `storage/fonts/` | 런타임에 자동 생성 (.gitignore) | - -> 이전에는 NanumGothic을 사용했으나, 운영서버에서 폰트 메트릭 생성이 실패하는 문제가 발생하여 Pretendard로 전환했다. Pretendard는 운영서버에 이미 설치되어 있어 호환성이 높다. - -### 4.2 Blade 뷰 font-family 지정 - -```css -/* ✅ 올바른 PDF 뷰 폰트 지정 */ -body { - font-family: 'Pretendard', 'Malgun Gothic', sans-serif; -} -``` - -> `Pretendard`가 DomPDF에서 사용되고, `Malgun Gothic`은 브라우저에서 HTML을 직접 볼 때의 fallback이다. - -### 4.3 자동 폰트 등록 패턴 (ensureKoreanFont) - -PDF를 생성하는 서비스에 다음 패턴을 적용한다. 최초 1회만 등록하고 이후는 캐시에서 로드한다. - -```php -/** - * DomPDF에 한글(Pretendard) 폰트 등록 (최초 1회만 실행) - * - * 폰트 원본은 resources/fonts/에 프로젝트와 함께 배포된다. - * 시스템 폰트 설치 여부에 의존하지 않는다. - */ -private function ensureKoreanFont(): void -{ - $fontDir = storage_path('fonts'); - $installedFile = $fontDir.'/installed-fonts.json'; - - // 이미 등록되어 있고 캐시 파일이 유효하면 스킵 - if (file_exists($installedFile)) { - $installed = json_decode(file_get_contents($installedFile), true) ?: []; - $hasFont = isset($installed['pretendard']); - $hasTtf = file_exists($fontDir.'/Pretendard-Regular.ttf'); - $hasMetric = ! empty(glob($fontDir.'/pretendard_normal_*.ufm*')); - if ($hasFont && $hasTtf && $hasMetric) { - return; - } - } - - // 프로젝트에 포함된 폰트 (Git으로 배포됨) - $fontSources = [ - 'normal' => resource_path('fonts/Pretendard-Regular.ttf'), - 'bold' => resource_path('fonts/Pretendard-Bold.ttf'), - ]; - - if (! file_exists($fontSources['normal'])) { - return; - } - - if (! is_dir($fontDir)) { - mkdir($fontDir, 0755, true); - } - - // storage/fonts/에 복사 후 DomPDF에 등록 - foreach ($fontSources as $weight => $src) { - $dst = $fontDir.'/'.basename($src); - if (! file_exists($dst)) { - copy($src, $dst); - } - } - - $dompdf = app(\Barryvdh\DomPDF\PDF::class)->getDomPDF(); - $dompdf->getOptions()->set('font_dir', $fontDir); - $dompdf->getOptions()->set('font_cache', $fontDir); - $fm = $dompdf->getFontMetrics(); - $fm->registerFont( - ['family' => 'pretendard', 'style' => 'normal', 'weight' => 'normal'], - $fontDir.'/Pretendard-Regular.ttf' - ); - $fm->registerFont( - ['family' => 'pretendard', 'style' => 'normal', 'weight' => 'bold'], - $fontDir.'/Pretendard-Bold.ttf' - ); - $fm->saveFontFamilies(); -} -``` - -### 4.4 PDF 생성 코드 패턴 - -```php -// ✅ 올바른 PDF 생성 패턴 -$this->ensureKoreanFont(); - -$pdf = Pdf::loadView('emails.payslip', ['payslipData' => $payslipData]) - ->setPaper('a4') - ->setOptions([ - 'font_dir' => storage_path('fonts'), - 'font_cache' => storage_path('fonts'), - 'enable_font_subsetting' => true, - ]); - -$pdfContent = $pdf->output(); -``` - -### 4.5 폰트 캐시 구조 - -``` -storage/fonts/ -├── installed-fonts.json ← DomPDF 폰트 레지스트리 -├── Pretendard-Regular.ttf ← resources/fonts/에서 복사된 원본 -├── Pretendard-Bold.ttf -├── pretendard_normal_*.ufm ← DomPDF가 생성한 메트릭 캐시 -├── pretendard_normal_*.ttf ← DomPDF가 생성한 서브셋 -└── pretendard_bold_*.* -``` - -> `storage/fonts/`는 `.gitignore`에 포함되어 있다. 각 환경에서 첫 PDF 생성 시 자동으로 생성된다. - ---- - -## 5. PDF 경량화 설정 - -### 5.1 배경 — 텍스트 PDF가 5MB - -급여명세서 PDF(텍스트만, 1페이지)가 약 5MB로 생성되는 문제가 발생. 원인은 DomPDF가 폰트 파일 전체(수천 개 한글 글리프 포함)를 PDF에 임베딩하기 때문이다. - -### 5.2 폰트 서브셋팅 (필수) +### 6.1 폰트 서브셋팅 (필수) `config/dompdf.php`에서 `enable_font_subsetting`을 `true`로 설정한다. PDF에 실제 사용된 문자의 글리프만 포함하여 용량을 대폭 줄인다. @@ -246,116 +215,115 @@ storage/fonts/ > 한글 폰트는 11,172개의 완성형 글자를 포함하지만, 급여명세서에 사용되는 글자는 100~200자 수준이다. 서브셋팅으로 99% 이상의 불필요한 글리프를 제거한다. -### 5.3 경량화 체크리스트 - -- [ ] `config/dompdf.php`의 `enable_font_subsetting` → `true` -- [ ] `enable_javascript` → `false` (PDF 내 JS 미사용 시) -- [ ] Blade 뷰에 불필요한 이미지/외부 리소스 미포함 -- [ ] `font-weight`는 `normal`/`bold`만 사용 (미등록 weight가 fallback 폰트를 트리거하면 용량 증가) - --- -## 6. 기존 코드 수정 가이드 +## 7. 표준 PDF 생성 패턴 -구글 폰트를 사용하는 기존 PDF 뷰를 발견하면 다음 절차로 수정한다. - -### 6.1 Blade 뷰 수정 - -```html - - - - - -``` - -### 6.2 서비스/컨트롤러 수정 +### 7.1 전체 코드 ```php -// ❌ 수정 전 -$pdf = Pdf::loadView('emails.payslip', ['payslipData' => $payslipData]) - ->setOptions(['isRemoteEnabled' => true]) +$pdf = Pdf::loadView('emails.payslip', ['payslipData' => $data]) ->setPaper('a4'); -// ✅ 수정 후 -$this->ensureKoreanFont(); -$pdf = Pdf::loadView('emails.payslip', ['payslipData' => $payslipData]) - ->setPaper('a4') - ->setOptions([ - 'font_dir' => storage_path('fonts'), - 'font_cache' => storage_path('fonts'), - 'enable_font_subsetting' => true, - ]); +$this->registerKoreanFont($pdf); // 동일 인스턴스에 등록 + +$pdfContent = $pdf->output(); ``` -### 6.3 적용 사례 +### 7.2 registerKoreanFont 구현 -| 파일 | 수정 내용 | -|------|----------| -| `resources/views/emails/payslip.blade.php` | `@import` 구글 폰트 삭제, `font-family` Pretendard 적용 | -| `app/Services/HR/PayrollService.php` | `isRemoteEnabled` 제거, `ensureKoreanFont()` 추가 | +```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(); +} +``` --- -## 7. 긴급 복구 — 운영서버 권한 오류 발생 시 +## 8. 폰트 캐시 구조 -이미 구글 폰트를 사용하는 코드가 배포되어 권한 오류가 발생한 경우의 즉시 조치이다. 근본 수정(구글 폰트 제거 + ensureKoreanFont 적용)을 반드시 병행한다. - -```bash -# Level 2 작업 — 사용자 확인 후 실행 -# 운영서버에서 폰트 캐시 디렉토리 권한 수정 -sudo chown -R www-data:webservice /home/webservice/mng/current/vendor/dompdf/dompdf/lib/fonts/ -sudo chmod -R 775 /home/webservice/mng/current/vendor/dompdf/dompdf/lib/fonts/ +``` +storage/fonts/ ← shared 디렉토리 (배포 간 유지) +├── installed-fonts.json ← DomPDF 폰트 레지스트리 +├── Pretendard-Regular.ttf ← resources/에서 복사된 원본 +├── Pretendard-Bold.ttf +├── pretendard_normal_*.ufm ← DomPDF 메트릭 캐시 (자동 생성) +├── pretendard_normal_*.ttf ← DomPDF 서브셋 (자동 생성) +└── pretendard_bold_*.* ``` -> 이 조치는 임시이다. 재배포 시 `vendor/`가 새로 생성되면 다시 발생한다. 반드시 `ensureKoreanFont` 패턴을 적용해야 근본 해결된다. +> `storage/fonts/`는 `.gitignore`에 포함되어 있다. 각 환경에서 첫 PDF 생성 시 자동으로 생성된다. --- -## 8. 체크리스트 +## 9. 체크리스트 ### PDF 뷰 작성 시 -- [ ] `@import url('https://fonts.googleapis.com/...')` 미포함 -- [ ] `` 미포함 +- [ ] 외부 폰트 URL 미포함 (`@import`, ``) - [ ] `font-family`에 `Pretendard` 포함 (DomPDF 한글 지원) - [ ] `font-family` fallback에 `Malgun Gothic`, `sans-serif` 포함 (브라우저용) - [ ] `font-weight`는 `normal`/`bold`만 사용 (800 이상 금지) ### PDF 생성 코드 작성 시 -- [ ] `isRemoteEnabled` 옵션 미사용 -- [ ] `ensureKoreanFont()` 호출 후 PDF 생성 -- [ ] `Pdf::loadView()->setPaper()->setOptions()` 패턴 사용 +- [ ] `setOptions()` 미사용 (config/dompdf.php에 선언) +- [ ] `Pdf::loadView()` 후 **동일 인스턴스**에 폰트 등록 +- [ ] 폰트 경로는 `storage_path()` 사용 (`resource_path()` 아님) +- [ ] `isRemoteEnabled` 미사용 -### 코드 리뷰 시 +### 배포 환경 확인 -- [ ] PDF 관련 Blade 뷰에 외부 폰트 URL 없음 -- [ ] DomPDF 옵션에 `isRemoteEnabled` 없음 -- [ ] 한글 PDF에 `ensureKoreanFont()` 호출 있음 - -### 폰트 배포 확인 - -- [ ] `resources/fonts/Pretendard-Regular.ttf` 존재 확인 (Git 관리) -- [ ] `resources/fonts/Pretendard-Bold.ttf` 존재 확인 (Git 관리) +- [ ] `config/dompdf.php` chroot에 `realpath(storage_path('fonts'))` 포함 - [ ] `storage/fonts/` 디렉토리 쓰기 권한 확인 -- [ ] 시스템 폰트에 의존하지 않음 확인 +- [ ] 폰트 TTF 파일이 `resources/fonts/`에 Git 관리됨 +- [ ] `enable_font_subsetting` → `true` --- ## 관련 문서 -- 서버 운영 매뉴얼: `dev/deploys/ops-manual/README.md` -- DomPDF 패키지: `barryvdh/laravel-dompdf` v3.1 -- DomPDF 설정: `mng/config/dompdf.php` — 서브셋팅, DPI 등 +- DomPDF 설정: `mng/config/dompdf.php` +- 구현 참조: `mng/app/Services/HR/PayrollService.php` — `registerKoreanFont()` - 폰트 원본: `mng/resources/fonts/` — Pretendard TTF (Git 관리) -- 구현 참조: `mng/app/Services/HR/PayrollService.php` — `ensureKoreanFont()` +- 서버 운영: `dev/deploys/ops-manual/README.md` --- -**최종 업데이트**: 2026-03-11 +**최종 업데이트**: 2026-03-11 \ No newline at end of file