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,133 +1,133 @@
# 자금일보 바로빌 자동동기화 및 계정과목 데이터 정리
**날짜:** 2026-03-11
**작업자:** Claude Code
---
## 변경 개요
두 가지 문제를 수정한다:
1. **자금일보 출금 내역 누락**`periodReport()`가 DB 캐시만 조회하고 바로빌 API 동기화를 트리거하지 않아, 최신 거래내역이 반영되지 않는 문제
2. **홈택스 분개 계정과목 오류** — 드롭다운에 2,549개 코드 표시(정상: 163개), 분개 기본값에 존재하지 않는 코드 사용
---
## 수정된 파일
| 파일 | 프로젝트 | 변경 내용 |
|------|---------|----------|
| `app/Services/Barobill/BarobillBankSyncService.php` | MNG (신규) | 바로빌 계좌 거래내역 동기화 서비스 |
| `app/Http/Controllers/Finance/DailyFundController.php` | MNG | `periodReport()`에 자동 동기화 호출 추가 |
| `resources/views/barobill/hometax/index.blade.php` | MNG | 분개 기본 계정과목 코드 수정 |
| `database/migrations/2026_03_11_101502_fix_account_codes_duplicate_data.php` | API (신규) | 중복 계정과목 비활성화 + 분개 코드 일괄 수정 |
---
## 상세 변경 사항
### 1. 바로빌 자동 동기화 서비스 (MNG)
**문제**: `DailyFundController::periodReport()``barobill_bank_transactions` 테이블만 조회한다. 바로빌 API에서 데이터를 가져오는 동기화는 `EaccountController`에서만 수행되어, 자금일보 페이지에서는 캐시가 갱신되지 않으면 최신 거래가 누락된다.
**해결**: `EaccountController`의 동기화 로직을 `BarobillBankSyncService`로 분리하여 재사용 가능하게 한다.
```
DailyFundController::periodReport()
├── BarobillBankSyncService::syncIfNeeded() ← 신규
│ ├── BarobillMember 조회 (바로빌 인증)
│ ├── SOAP 클라이언트 초기화
│ ├── 등록 계좌 목록 조회
│ └── 월별 청크 순회
│ ├── BankSyncStatus 캐시 판단
│ │ ├── 과거 월: 항상 캐시 (API 호출 안 함)
│ │ └── 현재 월: 10분 이내면 캐시
│ └── 필요 시 API 호출 → DB 캐시 저장
└── DB에서 거래내역 조회 (기존 로직)
```
**캐시 정책**:
| 조건 | 동작 |
|------|------|
| 과거 월 + 동기화 이력 있음 | 캐시 사용 (API 호출 안 함) |
| 현재 월 + 10분 이내 동기화 | 캐시 사용 |
| 현재 월 + 10분 초과 | API에서 갱신 |
| 동기화 이력 없음 | API에서 갱신 |
**실패 처리**: 동기화 실패 시 예외를 catch하고 로그만 남기며, 기존 DB 캐시로 응답을 계속한다.
---
### 2. 계정과목 중복 데이터 정리 (API 마이그레이션)
**문제**: `account_codes` 테이블에 비표준 코드가 대량 등록되어 드롭다운이 오염되었다.
| 코드 유형 | 건수 | 예시 | 상태 |
|----------|------|------|------|
| 3자리 더존 표준 코드 | 163개 | `101` 현금, `108` 외상매출금 | ✅ 정상 |
| 5자리 KIS 코드 (중복) | ~2,290개 | `10100` Cash, `10800` Accounts Receivable | ❌ 비활성화 |
| 1~2자리 카테고리 헤더 | ~96개 | `1` Assets, `10` Current Assets | ❌ 비활성화 |
**해결**: `LENGTH(code) != 3`인 코드를 `is_active = false`로 비활성화한다. 데이터는 삭제하지 않으며 필요 시 복원 가능하다.
---
### 3. 홈택스 분개 기본 코드 수정
**문제**: `getDefaultLines()` 함수에서 하드코딩된 계정과목 코드가 실제 DB 코드와 불일치한다.
| 거래 유형 | 항목 | 기존 코드 | 수정 코드 | 비고 |
|----------|------|----------|----------|------|
| 매출 | 부가세예수금 | `255` (장기미지급금) | `208` | 코드 불일치 |
| 매입 | 부가세대급금 | `135` (미존재) | `117` | DB에 없는 코드 |
| 매입 | 외상매입금 | `251` (장기차입금) | `201` | 코드 불일치 |
| 매입 | 적요명 | 상품매입 | 상품매출원가 | `501` 코드에 맞는 명칭 |
**API 마이그레이션으로 기존 분개 데이터도 일괄 수정**:
```sql
-- 135 → 117 (부가세대급금)
UPDATE hometax_invoice_journals SET account_code='117', account_name='부가세대급금' WHERE account_code='135';
-- 251 → 201 (외상매입금)
UPDATE hometax_invoice_journals SET account_code='201' WHERE account_code='251' AND account_name='외상매입금';
-- 255 → 208 (부가세예수금)
UPDATE hometax_invoice_journals SET account_code='208' WHERE account_code='255' AND account_name='부가세예수금';
```
---
## 배포
| 프로젝트 | 커밋 | develop | main |
|---------|------|---------|------|
| MNG | `ca36e8e5` (동기화 서비스), `afa64280` (계정과목 수정) | ✅ 푸시 완료 | ✅ 체리픽 완료 |
| API | `6f48b86` (데이터 마이그레이션) | ✅ 푸시 완료 | ✅ 체리픽 완료 |
Jenkins가 양쪽 서버에서 자동 배포 및 마이그레이션 실행을 완료했다.
---
## 테스트 체크리스트
- [x] 로컬 DB에서 `account_codes` 비표준 코드 비활성화 확인
- [x] 바로빌 동기화 후 2026-03-10 거래내역 10건 정상 조회
- [x] 홈택스 분개 기본값에 올바른 코드(`117`, `201`, `208`) 반영
- [x] 개발 서버 마이그레이션 실행 확인
- [x] 운영 서버 마이그레이션 자동 실행 확인
---
## 관련 문서
- [재무 관리](../../features/finance/README.md)
- [DB 스키마 - 재무](../../system/database/finance.md)
---
**최종 업데이트**: 2026-03-11
# 자금일보 바로빌 자동동기화 및 계정과목 데이터 정리
**날짜:** 2026-03-11
**작업자:** Claude Code
---
## 변경 개요
두 가지 문제를 수정한다:
1. **자금일보 출금 내역 누락**`periodReport()`가 DB 캐시만 조회하고 바로빌 API 동기화를 트리거하지 않아, 최신 거래내역이 반영되지 않는 문제
2. **홈택스 분개 계정과목 오류** — 드롭다운에 2,549개 코드 표시(정상: 163개), 분개 기본값에 존재하지 않는 코드 사용
---
## 수정된 파일
| 파일 | 프로젝트 | 변경 내용 |
|------|---------|----------|
| `app/Services/Barobill/BarobillBankSyncService.php` | MNG (신규) | 바로빌 계좌 거래내역 동기화 서비스 |
| `app/Http/Controllers/Finance/DailyFundController.php` | MNG | `periodReport()`에 자동 동기화 호출 추가 |
| `resources/views/barobill/hometax/index.blade.php` | MNG | 분개 기본 계정과목 코드 수정 |
| `database/migrations/2026_03_11_101502_fix_account_codes_duplicate_data.php` | API (신규) | 중복 계정과목 비활성화 + 분개 코드 일괄 수정 |
---
## 상세 변경 사항
### 1. 바로빌 자동 동기화 서비스 (MNG)
**문제**: `DailyFundController::periodReport()``barobill_bank_transactions` 테이블만 조회한다. 바로빌 API에서 데이터를 가져오는 동기화는 `EaccountController`에서만 수행되어, 자금일보 페이지에서는 캐시가 갱신되지 않으면 최신 거래가 누락된다.
**해결**: `EaccountController`의 동기화 로직을 `BarobillBankSyncService`로 분리하여 재사용 가능하게 한다.
```
DailyFundController::periodReport()
├── BarobillBankSyncService::syncIfNeeded() ← 신규
│ ├── BarobillMember 조회 (바로빌 인증)
│ ├── SOAP 클라이언트 초기화
│ ├── 등록 계좌 목록 조회
│ └── 월별 청크 순회
│ ├── BankSyncStatus 캐시 판단
│ │ ├── 과거 월: 항상 캐시 (API 호출 안 함)
│ │ └── 현재 월: 10분 이내면 캐시
│ └── 필요 시 API 호출 → DB 캐시 저장
└── DB에서 거래내역 조회 (기존 로직)
```
**캐시 정책**:
| 조건 | 동작 |
|------|------|
| 과거 월 + 동기화 이력 있음 | 캐시 사용 (API 호출 안 함) |
| 현재 월 + 10분 이내 동기화 | 캐시 사용 |
| 현재 월 + 10분 초과 | API에서 갱신 |
| 동기화 이력 없음 | API에서 갱신 |
**실패 처리**: 동기화 실패 시 예외를 catch하고 로그만 남기며, 기존 DB 캐시로 응답을 계속한다.
---
### 2. 계정과목 중복 데이터 정리 (API 마이그레이션)
**문제**: `account_codes` 테이블에 비표준 코드가 대량 등록되어 드롭다운이 오염되었다.
| 코드 유형 | 건수 | 예시 | 상태 |
|----------|------|------|------|
| 3자리 더존 표준 코드 | 163개 | `101` 현금, `108` 외상매출금 | ✅ 정상 |
| 5자리 KIS 코드 (중복) | ~2,290개 | `10100` Cash, `10800` Accounts Receivable | ❌ 비활성화 |
| 1~2자리 카테고리 헤더 | ~96개 | `1` Assets, `10` Current Assets | ❌ 비활성화 |
**해결**: `LENGTH(code) != 3`인 코드를 `is_active = false`로 비활성화한다. 데이터는 삭제하지 않으며 필요 시 복원 가능하다.
---
### 3. 홈택스 분개 기본 코드 수정
**문제**: `getDefaultLines()` 함수에서 하드코딩된 계정과목 코드가 실제 DB 코드와 불일치한다.
| 거래 유형 | 항목 | 기존 코드 | 수정 코드 | 비고 |
|----------|------|----------|----------|------|
| 매출 | 부가세예수금 | `255` (장기미지급금) | `208` | 코드 불일치 |
| 매입 | 부가세대급금 | `135` (미존재) | `117` | DB에 없는 코드 |
| 매입 | 외상매입금 | `251` (장기차입금) | `201` | 코드 불일치 |
| 매입 | 적요명 | 상품매입 | 상품매출원가 | `501` 코드에 맞는 명칭 |
**API 마이그레이션으로 기존 분개 데이터도 일괄 수정**:
```sql
-- 135 → 117 (부가세대급금)
UPDATE hometax_invoice_journals SET account_code='117', account_name='부가세대급금' WHERE account_code='135';
-- 251 → 201 (외상매입금)
UPDATE hometax_invoice_journals SET account_code='201' WHERE account_code='251' AND account_name='외상매입금';
-- 255 → 208 (부가세예수금)
UPDATE hometax_invoice_journals SET account_code='208' WHERE account_code='255' AND account_name='부가세예수금';
```
---
## 배포
| 프로젝트 | 커밋 | develop | main |
|---------|------|---------|------|
| MNG | `ca36e8e5` (동기화 서비스), `afa64280` (계정과목 수정) | ✅ 푸시 완료 | ✅ 체리픽 완료 |
| API | `6f48b86` (데이터 마이그레이션) | ✅ 푸시 완료 | ✅ 체리픽 완료 |
Jenkins가 양쪽 서버에서 자동 배포 및 마이그레이션 실행을 완료했다.
---
## 테스트 체크리스트
- [x] 로컬 DB에서 `account_codes` 비표준 코드 비활성화 확인
- [x] 바로빌 동기화 후 2026-03-10 거래내역 10건 정상 조회
- [x] 홈택스 분개 기본값에 올바른 코드(`117`, `201`, `208`) 반영
- [x] 개발 서버 마이그레이션 실행 확인
- [x] 운영 서버 마이그레이션 자동 실행 확인
---
## 관련 문서
- [재무 관리](../../features/finance/README.md)
- [DB 스키마 - 재무](../../system/database/finance.md)
---
**최종 업데이트**: 2026-03-11

View File

@@ -1,136 +1,136 @@
# 전자서명 체크박스, 전표 적요 동기화, 거래처 드롭다운, 바로빌 중복 키 수정
**날짜:** 2026-03-11
**작업자:** Claude Code
---
## 변경 개요
네 가지 개선/수정 사항:
1. **전자서명 템플릿 체크박스** — 체크박스 필드에 변수 연결 UI를 추가했다가, "배치 위치에 무조건 체크 표시" 방식으로 단순화
2. **전표 적요 → 자금일보 동기화** — 일반전표 적요 수정 시 일일자금일보에 반영되지 않던 문제 해결
3. **거래처 드롭다운 클릭 버그** — 다른 요소에서 포커스 이동 후 클릭 시 드롭다운이 즉시 닫히는 문제 해결
4. **바로빌 은행거래 중복 키 에러**`EaccountController` 동기화 시 `insert``insertOrIgnore` 변경
---
## 수정된 파일
| 파일 | 프로젝트 | 변경 내용 |
|------|---------|----------|
| `resources/views/esign/template-fields.blade.php` | MNG | 체크박스 필드 속성 패널에 안내 문구 + PDF 오버레이에 ☑ 표시 |
| `app/Http/Controllers/Finance/JournalEntryController.php` | MNG | `update()``BankTransactionOverride` 동기화 추가 |
| `resources/views/finance/journal-entries.blade.php` | MNG | `TradingPartnerSelect``justFocusedRef` 플래그 추가 |
| `app/Http/Controllers/Barobill/EaccountController.php` | MNG | `insert``insertOrIgnore` 변경 |
---
## 상세 변경 사항
### 1. 전자서명 템플릿 체크박스 단순화
**문제**: 체크박스 필드를 템플릿에 배치할 때 변수 연결 드롭다운이 표시되었으나, 선택 가능한 체크박스 변수가 없어 사용 불가.
**해결**: 체크박스는 "이 위치에 체크 표시를 넣겠다"는 의미이므로 변수 연결 자체가 불필요. 다음과 같이 단순화:
- 변수 연결 UI 제거 → "☑ 이 위치에 체크 표시가 렌더링됩니다" 안내 문구 표시
- PDF 오버레이에서 체크박스 필드는 ☑ 아이콘으로 시각적 표시
- 커스텀 변수의 체크박스 타입 옵션 제거
```
체크박스 필드 배치 → 해당 위치에 무조건 ☑ 렌더링
(변수 연결 불필요, 위치 정보만 저장)
```
---
### 2. 전표 적요 수정 → 자금일보 반영
**문제**: 일반전표의 적요를 수정하면 `journal_entries.description`만 업데이트되고, 일일자금일보가 참조하는 `barobill_bank_transactions.summary`는 변경되지 않음.
```
JournalEntry.description 수정
↓ (기존: 연결 없음)
일일자금일보 → barobill_bank_transactions.summary (이전 값 그대로)
```
**해결**: `JournalEntryController::update()` 트랜잭션 안에서, `source_type = 'bank_transaction'`인 전표의 적요 수정 시 `BankTransactionOverride``modified_summary`를 저장.
```
JournalEntry.description 수정
↓ (신규: 자동 동기화)
BankTransactionOverride.modified_summary 저장
일일자금일보 periodReport() → override 적용 → 수정된 적요 표시
```
**기존 `modified_cast` 보존**: override 저장 시 기존 `modified_cast` 값을 조회하여 유지.
---
### 3. 거래처 드롭다운 클릭 버그 수정
**문제**: `TradingPartnerSelect` 컴포넌트에서 다른 요소에 포커스가 있을 때 클릭하면 드롭다운이 열렸다가 즉시 닫힘.
**원인**: 이벤트 순서 — `onFocus` → 드롭다운 열림 → `onClick``setIsOpen(!isOpen)` 토글로 다시 닫힘. React 렌더 타이밍에 따라 `onClick``isOpen = true` 상태에서 실행되어 `false`로 전환.
**해결**: `justFocusedRef` 플래그 추가.
```javascript
onFocus justFocusedRef = true, setIsOpen(true)
onClick justFocusedRef가 true면 토글 건너뜀 (이미 열림)
justFocusedRef가 false면 정상 토글 (이미 포커스된 상태에서 클릭)
```
---
### 4. 바로빌 은행거래 동기화 중복 키 에러
**문제**: `EaccountController`의 거래내역 저장 시 `Duplicate entry` 에러 발생.
**원인**: 기존 레코드 조회 WHERE에 `summary`를 포함하지만, DB unique index(`barobill_bank_trans_unique`)에는 `summary`가 없음.
| 구분 | 포함 컬럼 |
|------|----------|
| WHERE 조회 | `tenant_id`, `bank_account_num`, `trans_dt`, `deposit`, `withdraw`, `balance`, **`summary`** |
| DB unique index | `tenant_id`, `bank_account_num`, `trans_dt`, `deposit`, `withdraw`, `balance` |
같은 거래인데 `summary`만 다른 경우(전각/반각 문자 차이 등) → WHERE에서 기존 레코드 못 찾음 → INSERT 시도 → unique index 위반.
**해결**: `DB::table()->insert()``DB::table()->insertOrIgnore()` 변경.
---
## 배포
| 커밋 | 내용 | develop | main |
|------|------|---------|------|
| `f11b1238` | 체크박스 변수 연결 추가 | ✅ | ✅ |
| `4f033172` | 체크박스 단순화 | ✅ | ✅ |
| `a97396df` | 전표 적요 → 자금일보 동기화 | ✅ | ✅ |
| `0be1fe7a` | 거래처 드롭다운 버그 수정 | ✅ | ✅ |
| `2d3f915a` | 바로빌 중복 키 수정 | ✅ | ✅ |
---
## 테스트 체크리스트
- [x] 전자서명 템플릿에서 체크박스 필드 배치 시 ☑ 안내 표시
- [x] 일반전표 적요 수정 후 저장 → 자금일보에서 수정된 적요 반영
- [x] 거래처 드롭다운을 마우스 클릭으로 열기 정상 동작
- [x] Tab 키로 거래처 이동 시 자동 열림 정상 동작
- [x] 바로빌 동기화 시 중복 거래에서 에러 없이 처리
---
## 관련 문서
- [전자서명](../../features/esign/README.md)
- [재무 관리](../../features/finance/README.md)
- [자금일보 동기화 변경](20260311_daily_fund_sync_and_account_codes_fix.md)
---
**최종 업데이트**: 2026-03-11
# 전자서명 체크박스, 전표 적요 동기화, 거래처 드롭다운, 바로빌 중복 키 수정
**날짜:** 2026-03-11
**작업자:** Claude Code
---
## 변경 개요
네 가지 개선/수정 사항:
1. **전자서명 템플릿 체크박스** — 체크박스 필드에 변수 연결 UI를 추가했다가, "배치 위치에 무조건 체크 표시" 방식으로 단순화
2. **전표 적요 → 자금일보 동기화** — 일반전표 적요 수정 시 일일자금일보에 반영되지 않던 문제 해결
3. **거래처 드롭다운 클릭 버그** — 다른 요소에서 포커스 이동 후 클릭 시 드롭다운이 즉시 닫히는 문제 해결
4. **바로빌 은행거래 중복 키 에러**`EaccountController` 동기화 시 `insert``insertOrIgnore` 변경
---
## 수정된 파일
| 파일 | 프로젝트 | 변경 내용 |
|------|---------|----------|
| `resources/views/esign/template-fields.blade.php` | MNG | 체크박스 필드 속성 패널에 안내 문구 + PDF 오버레이에 ☑ 표시 |
| `app/Http/Controllers/Finance/JournalEntryController.php` | MNG | `update()``BankTransactionOverride` 동기화 추가 |
| `resources/views/finance/journal-entries.blade.php` | MNG | `TradingPartnerSelect``justFocusedRef` 플래그 추가 |
| `app/Http/Controllers/Barobill/EaccountController.php` | MNG | `insert``insertOrIgnore` 변경 |
---
## 상세 변경 사항
### 1. 전자서명 템플릿 체크박스 단순화
**문제**: 체크박스 필드를 템플릿에 배치할 때 변수 연결 드롭다운이 표시되었으나, 선택 가능한 체크박스 변수가 없어 사용 불가.
**해결**: 체크박스는 "이 위치에 체크 표시를 넣겠다"는 의미이므로 변수 연결 자체가 불필요. 다음과 같이 단순화:
- 변수 연결 UI 제거 → "☑ 이 위치에 체크 표시가 렌더링됩니다" 안내 문구 표시
- PDF 오버레이에서 체크박스 필드는 ☑ 아이콘으로 시각적 표시
- 커스텀 변수의 체크박스 타입 옵션 제거
```
체크박스 필드 배치 → 해당 위치에 무조건 ☑ 렌더링
(변수 연결 불필요, 위치 정보만 저장)
```
---
### 2. 전표 적요 수정 → 자금일보 반영
**문제**: 일반전표의 적요를 수정하면 `journal_entries.description`만 업데이트되고, 일일자금일보가 참조하는 `barobill_bank_transactions.summary`는 변경되지 않음.
```
JournalEntry.description 수정
↓ (기존: 연결 없음)
일일자금일보 → barobill_bank_transactions.summary (이전 값 그대로)
```
**해결**: `JournalEntryController::update()` 트랜잭션 안에서, `source_type = 'bank_transaction'`인 전표의 적요 수정 시 `BankTransactionOverride``modified_summary`를 저장.
```
JournalEntry.description 수정
↓ (신규: 자동 동기화)
BankTransactionOverride.modified_summary 저장
일일자금일보 periodReport() → override 적용 → 수정된 적요 표시
```
**기존 `modified_cast` 보존**: override 저장 시 기존 `modified_cast` 값을 조회하여 유지.
---
### 3. 거래처 드롭다운 클릭 버그 수정
**문제**: `TradingPartnerSelect` 컴포넌트에서 다른 요소에 포커스가 있을 때 클릭하면 드롭다운이 열렸다가 즉시 닫힘.
**원인**: 이벤트 순서 — `onFocus` → 드롭다운 열림 → `onClick``setIsOpen(!isOpen)` 토글로 다시 닫힘. React 렌더 타이밍에 따라 `onClick``isOpen = true` 상태에서 실행되어 `false`로 전환.
**해결**: `justFocusedRef` 플래그 추가.
```javascript
onFocus justFocusedRef = true, setIsOpen(true)
onClick justFocusedRef가 true면 토글 건너뜀 (이미 열림)
justFocusedRef가 false면 정상 토글 (이미 포커스된 상태에서 클릭)
```
---
### 4. 바로빌 은행거래 동기화 중복 키 에러
**문제**: `EaccountController`의 거래내역 저장 시 `Duplicate entry` 에러 발생.
**원인**: 기존 레코드 조회 WHERE에 `summary`를 포함하지만, DB unique index(`barobill_bank_trans_unique`)에는 `summary`가 없음.
| 구분 | 포함 컬럼 |
|------|----------|
| WHERE 조회 | `tenant_id`, `bank_account_num`, `trans_dt`, `deposit`, `withdraw`, `balance`, **`summary`** |
| DB unique index | `tenant_id`, `bank_account_num`, `trans_dt`, `deposit`, `withdraw`, `balance` |
같은 거래인데 `summary`만 다른 경우(전각/반각 문자 차이 등) → WHERE에서 기존 레코드 못 찾음 → INSERT 시도 → unique index 위반.
**해결**: `DB::table()->insert()``DB::table()->insertOrIgnore()` 변경.
---
## 배포
| 커밋 | 내용 | develop | main |
|------|------|---------|------|
| `f11b1238` | 체크박스 변수 연결 추가 | ✅ | ✅ |
| `4f033172` | 체크박스 단순화 | ✅ | ✅ |
| `a97396df` | 전표 적요 → 자금일보 동기화 | ✅ | ✅ |
| `0be1fe7a` | 거래처 드롭다운 버그 수정 | ✅ | ✅ |
| `2d3f915a` | 바로빌 중복 키 수정 | ✅ | ✅ |
---
## 테스트 체크리스트
- [x] 전자서명 템플릿에서 체크박스 필드 배치 시 ☑ 안내 표시
- [x] 일반전표 적요 수정 후 저장 → 자금일보에서 수정된 적요 반영
- [x] 거래처 드롭다운을 마우스 클릭으로 열기 정상 동작
- [x] Tab 키로 거래처 이동 시 자동 열림 정상 동작
- [x] 바로빌 동기화 시 중복 거래에서 에러 없이 처리
---
## 관련 문서
- [전자서명](../../features/esign/README.md)
- [재무 관리](../../features/finance/README.md)
- [자금일보 동기화 변경](20260311_daily_fund_sync_and_account_codes_fix.md)
---
**최종 업데이트**: 2026-03-11

View File

@@ -1,327 +1,327 @@
# API 품질 개선 — 테스트 인프라 + 56개 테스트 + N+1 최적화
**날짜:** 2026-03-14
**작업자:** R&D 개발실장 + Claude Code
**배포 대상:** 개발 서버 (API develop 브랜치)
---
## 변경 개요
API 프로젝트의 기술 부채 분석 결과(D1~D2)에 따라 **테스트 커버리지 확충**과 **N+1 쿼리 최적화**를 수행했다. 비즈니스 핵심 흐름(수주→재고→결재→작업지시)에 대한 안전망을 확보하고, 대량 처리 시 쿼리 95%를 절감했다.
---
## 1. 왜 이 작업을 했는가 (근거)
### 1.1 기술 부채 분석 (근거 문서)
`system/api-analysis-report.md`에서 식별한 8건의 기술 부채 중 최우선 2건을 착수했다.
| ID | 영역 | 현황 (수정 전) | 영향도 |
|:--:|------|-------------|:------:|
| **D1** | 테스트 부재 | 165개 (1,400 EP 대비 부족), 핵심 도메인 미커버 | 높음 |
| **D2** | N+1 쿼리 | 루프 내 개별 DB 조회 3건 발견 | 높음 |
### 1.2 D1이 먼저인 이유
테스트가 없으면 코드 수정 후 "고쳐도 안전한가?"를 검증할 수 없다. D2(N+1 최적화) 같은 성능 개선을 안전하게 수행하려면 테스트 안전망이 선행되어야 한다.
### 1.3 D2 수정 대상 선정 근거
`app/Services/` 전체를 정적 분석하여 **foreach 루프 안에서 DB 쿼리를 실행하는 패턴**을 검색했다. 발견된 3건 모두 데이터 양에 비례하여 쿼리가 선형 증가하는 구조였다.
---
## 2. D1: 테스트 커버리지 확충
### 2.1 테스트 인프라 정비
기존 11개 테스트 파일이 동일한 setUp 코드(약 40줄)를 매번 복붙하고 있었다.
**수정 내용:**
| 파일 | 변경 | 이유 |
|------|------|------|
| `tests/TestCase.php` | 공통 메서드 4개 추가 | 중복 setUp 코드 제거, 신규 테스트 작성 속도 향상 |
| 기존 테스트 11개 | `private` 프로퍼티 → TestCase 상속 | TestCase 공통화에 따른 호환성 |
**추가된 공통 메서드:**
| 메서드 | 역할 |
|--------|------|
| `setUpAuthenticatedUser()` | API Key + Tenant + User + 로그인 토큰 일괄 생성 |
| `api($method, $uri, $data)` | 인증된 API 요청 헬퍼 |
| `assertApiSuccess($response)` | 표준 응답 구조 검증 |
| `assertApiPaginated($response)` | 페이지네이션 응답 검증 |
### 2.2 Factory 생성
테스트 데이터를 간편하게 생성하기 위해 Factory 5개를 추가했다.
| Factory | 모델 | 이유 |
|---------|------|------|
| `TenantFactory` | Tenant | 모든 테스트의 기본 |
| `ClientFactory` | Client | 수주 테스트에 거래처 필요 |
| `OrderFactory` | Order | 수주 CRUD + 상태전이 테스트 |
| `StockFactory` | Stock | 재고 FIFO 테스트 |
| `StockLotFactory` | StockLot | LOT 단위 입출고 테스트 |
### 2.3 신규 테스트 56개
| 도메인 | 파일 | 테스트 수 | 검증 내용 |
|--------|------|:--------:|---------|
| **수주 (Order)** | `tests/Feature/Orders/OrderApiTest.php` | 12 | CRUD, 상태변경(DRAFT→CONFIRMED→CANCELLED), 일괄삭제, 인증 |
| **재고 (Stock)** | `tests/Feature/Inventory/StockApiTest.php` | 13 | API 목록/통계, FIFO 차감, LOT 걸침 처리, 예약/해제, 거래이력, 상태 자동계산 |
| **결재 (Approval)** | `tests/Feature/Approval/ApprovalApiTest.php` | 15 | CRUD, 상신→승인/반려/회수 워크플로우, 결재자 별도 로그인, 결재함/참조함/완료함 |
| **작업지시 (WorkOrder)** | `tests/Feature/Production/WorkOrderApiTest.php` | 16 | CRUD, 상태전이 4단계(미배정→대기→준비→진행→완료), 담당자배정, 공정단계, 자재조회 |
**커버된 핵심 비즈니스 흐름:**
```
견적 → 수주(12) → 재고예약(13) → 작업지시(16) → 결재(15)
FIFO 검증 상태전이 검증 워크플로우 검증
```
### 2.4 테스트 실행 결과
```
수정 전: 165개 테스트
수정 후: 221개 테스트 (+56개, +34%)
최종 실행: 164개 통과 / 3개 Skip (기존 라우트 충돌)
실행 시간: ~12초
```
### 2.5 테스트 중 발견된 문제
| 발견 | 내용 | 후속 조치 |
|------|------|----------|
| 빈 데이터 수주 생성 허용 | `POST /api/v1/orders` 에 빈 body 전송 시 200 반환 | `StoreOrderRequest` 검증 강화 필요 (D4) |
| 기존 테스트 실패 3건 | `PrefixResolverTest`, `BendingLotPipelineTest` — 이번 변경과 무관 | 별도 수정 필요 |
| `ItemMasterApiTest` 에러 | `section_id` 컬럼 미존재 — 마이그레이션 불일치 | 별도 수정 필요 |
---
## 3. D2: N+1 쿼리 최적화
### 3.1 수정 대상 3건
| # | 파일 | 메서드 | 문제 | 쿼리 수 (수정 전) |
|:-:|------|--------|------|:-----------------:|
| 1 | `WorkOrderService.php` | `getMaterials()` | 루프 내 `Item::find()` + 중첩 루프 내 `Item::find()` | 1 + N + M |
| 2 | `OrderService.php` | `createWorkOrderFromOrder()` | 루프 내 `DB::table('items')->value()` + `DB::table('process_items')->value()` | 1 + 2N |
| 3 | `OrderService.php` | `checkBendingStockForOrder()` | 루프 내 `StockService::getAvailableStock()` 개별 호출 | 1 + N |
### 3.2 수정 방법 — 배치 사전 조회 패턴
모든 수정에 동일한 패턴을 적용했다:
```
수정 전: foreach (items) { DB::find(id); } ← N+1
수정 후: map = DB::whereIn(ids)->keyBy('id'); ← 1회 배치
foreach (items) { map[id]; } ← 메모리 참조
```
### 3.3 수정 상세
**수정 1: `WorkOrderService::getMaterials()` (라인 1470~1500)**
```php
// 수정 전: 루프 안에서 개별 조회
foreach ($workOrder->items as $woItem) {
$item = Item::find($woItem->item_id); // N+1
foreach ($item->bom as $bomItem) {
$childItem = Item::find($childItemId); // N+1 (중첩)
}
}
// 수정 후: 루프 전 배치 조회
$bomItemsMap = Item::whereIn('id', $parentIds)->get()->keyBy('id');
$bomChildItemsMap = Item::whereIn('id', $childIds)->get()->keyBy('id');
foreach ($workOrder->items as $woItem) {
$item = $bomItemsMap[$woItem->item_id]; // 메모리 참조
foreach ($item->bom as $bomItem) {
$childItem = $bomChildItemsMap[$childItemId]; // 메모리 참조
}
}
```
**수정 2: `OrderService::createWorkOrderFromOrder()` (라인 1239~1297)**
```php
// 수정 전: fallback에서 루프마다 DB 쿼리 x2
foreach ($order->items as $orderItem) {
$resolvedId = DB::table('items')->where('code', $code)->value('id'); // N+1
$pi = DB::table('process_items')->where('item_id', $id)->value('pid'); // N+1
}
// 수정 후: 루프 전 모든 item_code, process_items 일괄 조회
$codeToIdMap = DB::table('items')->whereIn('code', $allCodes)->get()->keyBy('code');
$itemProcessMap = DB::table('process_items')->whereIn('item_id', $allIds)->get()->keyBy('item_id');
foreach ($order->items as $orderItem) {
$resolvedId = $codeToIdMap[$code] ?? null; // 메모리 참조
$processId = $itemProcessMap[$resolvedId] ?? null; // 메모리 참조
}
```
**수정 3: `OrderService::checkBendingStockForOrder()` (라인 1880~1885)**
```php
// 수정 전: 루프마다 StockService 호출 (내부에서 DB 쿼리)
foreach ($bendingItems as $item) {
$stockInfo = $stockService->getAvailableStock($item->id); // N+1
}
// 수정 후: 배치 조회 후 맵 참조
$stocksMap = Stock::whereIn('item_id', $ids)->get()->keyBy('item_id');
foreach ($bendingItems as $item) {
$stock = $stocksMap->get($item->id); // 메모리 참조
}
```
### 3.4 성능 개선 효과
| 시나리오 | 수정 전 쿼리 | 수정 후 쿼리 | 절감률 |
|---------|:----------:|:----------:|:-----:|
| 수주 50개 품목 → 작업지시 생성 | ~150 | ~8 | **95%** |
| 작업지시 자재 조회 (BOM 20개) | ~45 | ~3 | **93%** |
| 벤딩 재고 확인 (30개 품목) | ~31 | ~2 | **94%** |
### 3.5 회귀 테스트 결과
수정 후 전체 테스트 164개 통과, 기존 기능에 영향 없음 확인.
---
## 수정된 파일 전체 목록
### 신규 생성 (10개)
| 파일 | 설명 |
|------|------|
| `tests/Feature/Orders/OrderApiTest.php` | 수주 API 테스트 12개 |
| `tests/Feature/Inventory/StockApiTest.php` | 재고 API + FIFO 테스트 13개 |
| `tests/Feature/Approval/ApprovalApiTest.php` | 결재 워크플로우 테스트 15개 |
| `tests/Feature/Production/WorkOrderApiTest.php` | 작업지시 테스트 16개 |
| `database/factories/TenantFactory.php` | Tenant 모델 Factory |
| `database/factories/ClientFactory.php` | Client 모델 Factory |
| `database/factories/OrderFactory.php` | Order 모델 Factory (상태 빌더 포함) |
| `database/factories/StockFactory.php` | Stock 모델 Factory |
| `database/factories/StockLotFactory.php` | StockLot 모델 Factory |
### 수정 (14개)
| 파일 | 변경 내용 |
|------|----------|
| `tests/TestCase.php` | 공통 헬퍼 4개 추가 (인증, API 호출, 응답 검증) |
| `tests/Feature/Account/AccountApiTest.php` | `private` → TestCase 상속, 중복 제거 |
| `tests/Feature/BadDebt/BadDebtApiTest.php` | 동일 |
| `tests/Feature/Category/CategoryApiTest.php` | 동일 |
| `tests/Feature/Company/CompanyApiTest.php` | 동일 |
| `tests/Feature/ItemMaster/ItemMasterApiTest.php` | 동일 |
| `tests/Feature/Payment/PaymentApiTest.php` | 동일 |
| `tests/Feature/Popup/PopupApiTest.php` | 동일 |
| `tests/Feature/Production/BendingLotPipelineTest.php` | `use DatabaseTransactions` 중복 제거 |
| `tests/Feature/Subscription/SubscriptionApiTest.php` | 동일 |
| `tests/Feature/User/NotificationSettingApiTest.php` | 동일 |
| `tests/Feature/User/UserInvitationApiTest.php` | 동일 |
| `app/Services/WorkOrderService.php` | N+1 수정 — BOM 배치 사전 로드 |
| `app/Services/OrderService.php` | N+1 수정 — item_code/process_items 배치 조회, Stock 배치 조회 |
---
## 4. 운영 코드 안전성 검토
배포 후 수정된 운영 코드(테스트 파일 제외)가 기존 API 동작에 영향을 미치는지 코드 리뷰 + 전체 테스트로 검증했다.
### 4.1 검토 대상
실제 운영 코드를 수정한 파일은 **2개뿐**이다. 나머지 22개는 모두 테스트/Factory 파일이다.
| 파일 | 수정 메서드 | 수정 내용 |
|------|-----------|----------|
| `WorkOrderService.php` | `getMaterials()` | BOM 루프 내 `find()` → 배치 사전 로드 |
| `OrderService.php` | `createWorkOrderFromOrder()` | fallback 루프 내 DB 쿼리 → 배치 사전 조회 |
| `OrderService.php` | `checkBendingStockForOrder()` | StockService 루프 호출 → 배치 조회 |
### 4.2 동작 동등성 검증 (수정 전 = 수정 후)
| 수정 | 판정 | 근거 |
|------|:----:|------|
| `getMaterials()` BOM 배치 | **동등** | null 처리, 빈 배열, BOM 없는 경우 모두 동일. `$bomItemsMap[$id] ?? null``find($id)`와 동일한 null 반환 |
| `createWorkOrderFromOrder()` fallback | **동등** | 사전 배치 조회 결과가 즉석 조회와 동일. `DB::transaction` 내부이므로 중간 데이터 변경 없음. 캐시(`codeToIdMap`) 동작도 동일 |
| `checkBendingStockForOrder()` Stock | **동등** | `Stock::whereIn()` 결과가 `StockService::getAvailableStock()` 결과와 동일. `BelongsToTenant` 스코프 + 명시적 `tenant_id` 조건으로 격리 보장 |
### 4.3 엣지 케이스 검증
| 케이스 | 수정 전 | 수정 후 | 동일? |
|--------|--------|--------|:-----:|
| `item_id`가 null인 품목 | `if ($woItem->item_id)` skip | 맵에 포함되지 않아 동일하게 skip | ✅ |
| BOM JSON이 비어있는 품목 | `empty($item->bom)` skip | 동일 | ✅ |
| DB에 없는 `item_code` | `find()` → null | `$map[$code] ?? null` → null | ✅ |
| 재고가 0인 품목 | Stock 없음 → available_qty=0 | `$stocksMap->get($id)` → null → 0 | ✅ |
| 빈 주문 (items 0건) | 루프 미실행 | 배치 조회도 빈 배열, 루프 미실행 | ✅ |
### 4.4 전체 테스트 실행 결과
```
PHPUnit 11.5.27 / PHP 8.4.18
전체: 256개 테스트 실행
통과: 243개
실패: 7개 (모두 수정 전부터 존재하던 기존 문제)
Skip: 6개
이번 수정으로 인한 실패: 0건
```
**실패 7건 상세 (모두 기존 문제):**
| 테스트 | 원인 | 이번 수정과 관계 |
|--------|------|:--------------:|
| `PrefixResolverTest` (1건) | Unit 로직 불일치 (XX vs CF) | 무관 |
| `BendingLotPipelineTest` (3건) | TENANT_ID=287 고정, 로컬 DB 데이터 없음 | 무관 |
| `ItemMasterApiTest` (3건) | `section_id` 컬럼 미존재 (마이그레이션 불일치) | 무관 |
### 4.5 발견된 기존 문제 (수정과 무관, 별도 대응 필요)
`process_items` 테이블 조회에 `tenant_id` 필터가 없다. 수정 전부터 존재하던 문제이며 이번 수정으로 악화되지 않았다. 멀티테넌트 격리가 필요하면 별도 수정이 필요하다.
```php
// OrderService.php — tenant_id 조건 누락 (수정 전/후 동일)
DB::table('process_items')
->whereIn('item_id', $ids)
->where('is_active', true) // tenant_id 없음
->get();
```
### 4.6 결론
**이번 수정으로 기존 API 동작이 깨지는 경우는 없다.** 수정 전과 후의 결과가 정확히 동일하며, 쿼리 수만 줄어든 순수 성능 개선이다.
---
## 테스트 체크리스트
- [x] TestCase 공통 헬퍼 작성 및 기존 11개 테스트 호환 확인
- [x] Factory 5개 생성 (Tenant, Client, Order, Stock, StockLot)
- [x] Order API 테스트 12개 통과
- [x] Stock API + FIFO 테스트 13개 통과
- [x] Approval 워크플로우 테스트 15개 통과
- [x] WorkOrder API 테스트 16개 통과
- [x] N+1 쿼리 3건 배치 조회로 최적화
- [x] 전체 테스트 164개 회귀 없음 확인
- [x] 개발 서버 배포 완료 (2026-03-14)
---
## 관련 문서
- [API 구조 분석 및 개선 로드맵](../../system/api-analysis-report.md) — D1~D8 기술 부채 정의
- [API 개발 규칙](../standards/api-rules.md) — Service-First, FormRequest 컨벤션
- [품질 체크리스트](../standards/quality-checklist.md)
---
**최종 업데이트**: 2026-03-14
# API 품질 개선 — 테스트 인프라 + 56개 테스트 + N+1 최적화
**날짜:** 2026-03-14
**작업자:** R&D 개발실장 + Claude Code
**배포 대상:** 개발 서버 (API develop 브랜치)
---
## 변경 개요
API 프로젝트의 기술 부채 분석 결과(D1~D2)에 따라 **테스트 커버리지 확충**과 **N+1 쿼리 최적화**를 수행했다. 비즈니스 핵심 흐름(수주→재고→결재→작업지시)에 대한 안전망을 확보하고, 대량 처리 시 쿼리 95%를 절감했다.
---
## 1. 왜 이 작업을 했는가 (근거)
### 1.1 기술 부채 분석 (근거 문서)
`system/api-analysis-report.md`에서 식별한 8건의 기술 부채 중 최우선 2건을 착수했다.
| ID | 영역 | 현황 (수정 전) | 영향도 |
|:--:|------|-------------|:------:|
| **D1** | 테스트 부재 | 165개 (1,400 EP 대비 부족), 핵심 도메인 미커버 | 높음 |
| **D2** | N+1 쿼리 | 루프 내 개별 DB 조회 3건 발견 | 높음 |
### 1.2 D1이 먼저인 이유
테스트가 없으면 코드 수정 후 "고쳐도 안전한가?"를 검증할 수 없다. D2(N+1 최적화) 같은 성능 개선을 안전하게 수행하려면 테스트 안전망이 선행되어야 한다.
### 1.3 D2 수정 대상 선정 근거
`app/Services/` 전체를 정적 분석하여 **foreach 루프 안에서 DB 쿼리를 실행하는 패턴**을 검색했다. 발견된 3건 모두 데이터 양에 비례하여 쿼리가 선형 증가하는 구조였다.
---
## 2. D1: 테스트 커버리지 확충
### 2.1 테스트 인프라 정비
기존 11개 테스트 파일이 동일한 setUp 코드(약 40줄)를 매번 복붙하고 있었다.
**수정 내용:**
| 파일 | 변경 | 이유 |
|------|------|------|
| `tests/TestCase.php` | 공통 메서드 4개 추가 | 중복 setUp 코드 제거, 신규 테스트 작성 속도 향상 |
| 기존 테스트 11개 | `private` 프로퍼티 → TestCase 상속 | TestCase 공통화에 따른 호환성 |
**추가된 공통 메서드:**
| 메서드 | 역할 |
|--------|------|
| `setUpAuthenticatedUser()` | API Key + Tenant + User + 로그인 토큰 일괄 생성 |
| `api($method, $uri, $data)` | 인증된 API 요청 헬퍼 |
| `assertApiSuccess($response)` | 표준 응답 구조 검증 |
| `assertApiPaginated($response)` | 페이지네이션 응답 검증 |
### 2.2 Factory 생성
테스트 데이터를 간편하게 생성하기 위해 Factory 5개를 추가했다.
| Factory | 모델 | 이유 |
|---------|------|------|
| `TenantFactory` | Tenant | 모든 테스트의 기본 |
| `ClientFactory` | Client | 수주 테스트에 거래처 필요 |
| `OrderFactory` | Order | 수주 CRUD + 상태전이 테스트 |
| `StockFactory` | Stock | 재고 FIFO 테스트 |
| `StockLotFactory` | StockLot | LOT 단위 입출고 테스트 |
### 2.3 신규 테스트 56개
| 도메인 | 파일 | 테스트 수 | 검증 내용 |
|--------|------|:--------:|---------|
| **수주 (Order)** | `tests/Feature/Orders/OrderApiTest.php` | 12 | CRUD, 상태변경(DRAFT→CONFIRMED→CANCELLED), 일괄삭제, 인증 |
| **재고 (Stock)** | `tests/Feature/Inventory/StockApiTest.php` | 13 | API 목록/통계, FIFO 차감, LOT 걸침 처리, 예약/해제, 거래이력, 상태 자동계산 |
| **결재 (Approval)** | `tests/Feature/Approval/ApprovalApiTest.php` | 15 | CRUD, 상신→승인/반려/회수 워크플로우, 결재자 별도 로그인, 결재함/참조함/완료함 |
| **작업지시 (WorkOrder)** | `tests/Feature/Production/WorkOrderApiTest.php` | 16 | CRUD, 상태전이 4단계(미배정→대기→준비→진행→완료), 담당자배정, 공정단계, 자재조회 |
**커버된 핵심 비즈니스 흐름:**
```
견적 → 수주(12) → 재고예약(13) → 작업지시(16) → 결재(15)
FIFO 검증 상태전이 검증 워크플로우 검증
```
### 2.4 테스트 실행 결과
```
수정 전: 165개 테스트
수정 후: 221개 테스트 (+56개, +34%)
최종 실행: 164개 통과 / 3개 Skip (기존 라우트 충돌)
실행 시간: ~12초
```
### 2.5 테스트 중 발견된 문제
| 발견 | 내용 | 후속 조치 |
|------|------|----------|
| 빈 데이터 수주 생성 허용 | `POST /api/v1/orders` 에 빈 body 전송 시 200 반환 | `StoreOrderRequest` 검증 강화 필요 (D4) |
| 기존 테스트 실패 3건 | `PrefixResolverTest`, `BendingLotPipelineTest` — 이번 변경과 무관 | 별도 수정 필요 |
| `ItemMasterApiTest` 에러 | `section_id` 컬럼 미존재 — 마이그레이션 불일치 | 별도 수정 필요 |
---
## 3. D2: N+1 쿼리 최적화
### 3.1 수정 대상 3건
| # | 파일 | 메서드 | 문제 | 쿼리 수 (수정 전) |
|:-:|------|--------|------|:-----------------:|
| 1 | `WorkOrderService.php` | `getMaterials()` | 루프 내 `Item::find()` + 중첩 루프 내 `Item::find()` | 1 + N + M |
| 2 | `OrderService.php` | `createWorkOrderFromOrder()` | 루프 내 `DB::table('items')->value()` + `DB::table('process_items')->value()` | 1 + 2N |
| 3 | `OrderService.php` | `checkBendingStockForOrder()` | 루프 내 `StockService::getAvailableStock()` 개별 호출 | 1 + N |
### 3.2 수정 방법 — 배치 사전 조회 패턴
모든 수정에 동일한 패턴을 적용했다:
```
수정 전: foreach (items) { DB::find(id); } ← N+1
수정 후: map = DB::whereIn(ids)->keyBy('id'); ← 1회 배치
foreach (items) { map[id]; } ← 메모리 참조
```
### 3.3 수정 상세
**수정 1: `WorkOrderService::getMaterials()` (라인 1470~1500)**
```php
// 수정 전: 루프 안에서 개별 조회
foreach ($workOrder->items as $woItem) {
$item = Item::find($woItem->item_id); // N+1
foreach ($item->bom as $bomItem) {
$childItem = Item::find($childItemId); // N+1 (중첩)
}
}
// 수정 후: 루프 전 배치 조회
$bomItemsMap = Item::whereIn('id', $parentIds)->get()->keyBy('id');
$bomChildItemsMap = Item::whereIn('id', $childIds)->get()->keyBy('id');
foreach ($workOrder->items as $woItem) {
$item = $bomItemsMap[$woItem->item_id]; // 메모리 참조
foreach ($item->bom as $bomItem) {
$childItem = $bomChildItemsMap[$childItemId]; // 메모리 참조
}
}
```
**수정 2: `OrderService::createWorkOrderFromOrder()` (라인 1239~1297)**
```php
// 수정 전: fallback에서 루프마다 DB 쿼리 x2
foreach ($order->items as $orderItem) {
$resolvedId = DB::table('items')->where('code', $code)->value('id'); // N+1
$pi = DB::table('process_items')->where('item_id', $id)->value('pid'); // N+1
}
// 수정 후: 루프 전 모든 item_code, process_items 일괄 조회
$codeToIdMap = DB::table('items')->whereIn('code', $allCodes)->get()->keyBy('code');
$itemProcessMap = DB::table('process_items')->whereIn('item_id', $allIds)->get()->keyBy('item_id');
foreach ($order->items as $orderItem) {
$resolvedId = $codeToIdMap[$code] ?? null; // 메모리 참조
$processId = $itemProcessMap[$resolvedId] ?? null; // 메모리 참조
}
```
**수정 3: `OrderService::checkBendingStockForOrder()` (라인 1880~1885)**
```php
// 수정 전: 루프마다 StockService 호출 (내부에서 DB 쿼리)
foreach ($bendingItems as $item) {
$stockInfo = $stockService->getAvailableStock($item->id); // N+1
}
// 수정 후: 배치 조회 후 맵 참조
$stocksMap = Stock::whereIn('item_id', $ids)->get()->keyBy('item_id');
foreach ($bendingItems as $item) {
$stock = $stocksMap->get($item->id); // 메모리 참조
}
```
### 3.4 성능 개선 효과
| 시나리오 | 수정 전 쿼리 | 수정 후 쿼리 | 절감률 |
|---------|:----------:|:----------:|:-----:|
| 수주 50개 품목 → 작업지시 생성 | ~150 | ~8 | **95%** |
| 작업지시 자재 조회 (BOM 20개) | ~45 | ~3 | **93%** |
| 벤딩 재고 확인 (30개 품목) | ~31 | ~2 | **94%** |
### 3.5 회귀 테스트 결과
수정 후 전체 테스트 164개 통과, 기존 기능에 영향 없음 확인.
---
## 수정된 파일 전체 목록
### 신규 생성 (10개)
| 파일 | 설명 |
|------|------|
| `tests/Feature/Orders/OrderApiTest.php` | 수주 API 테스트 12개 |
| `tests/Feature/Inventory/StockApiTest.php` | 재고 API + FIFO 테스트 13개 |
| `tests/Feature/Approval/ApprovalApiTest.php` | 결재 워크플로우 테스트 15개 |
| `tests/Feature/Production/WorkOrderApiTest.php` | 작업지시 테스트 16개 |
| `database/factories/TenantFactory.php` | Tenant 모델 Factory |
| `database/factories/ClientFactory.php` | Client 모델 Factory |
| `database/factories/OrderFactory.php` | Order 모델 Factory (상태 빌더 포함) |
| `database/factories/StockFactory.php` | Stock 모델 Factory |
| `database/factories/StockLotFactory.php` | StockLot 모델 Factory |
### 수정 (14개)
| 파일 | 변경 내용 |
|------|----------|
| `tests/TestCase.php` | 공통 헬퍼 4개 추가 (인증, API 호출, 응답 검증) |
| `tests/Feature/Account/AccountApiTest.php` | `private` → TestCase 상속, 중복 제거 |
| `tests/Feature/BadDebt/BadDebtApiTest.php` | 동일 |
| `tests/Feature/Category/CategoryApiTest.php` | 동일 |
| `tests/Feature/Company/CompanyApiTest.php` | 동일 |
| `tests/Feature/ItemMaster/ItemMasterApiTest.php` | 동일 |
| `tests/Feature/Payment/PaymentApiTest.php` | 동일 |
| `tests/Feature/Popup/PopupApiTest.php` | 동일 |
| `tests/Feature/Production/BendingLotPipelineTest.php` | `use DatabaseTransactions` 중복 제거 |
| `tests/Feature/Subscription/SubscriptionApiTest.php` | 동일 |
| `tests/Feature/User/NotificationSettingApiTest.php` | 동일 |
| `tests/Feature/User/UserInvitationApiTest.php` | 동일 |
| `app/Services/WorkOrderService.php` | N+1 수정 — BOM 배치 사전 로드 |
| `app/Services/OrderService.php` | N+1 수정 — item_code/process_items 배치 조회, Stock 배치 조회 |
---
## 4. 운영 코드 안전성 검토
배포 후 수정된 운영 코드(테스트 파일 제외)가 기존 API 동작에 영향을 미치는지 코드 리뷰 + 전체 테스트로 검증했다.
### 4.1 검토 대상
실제 운영 코드를 수정한 파일은 **2개뿐**이다. 나머지 22개는 모두 테스트/Factory 파일이다.
| 파일 | 수정 메서드 | 수정 내용 |
|------|-----------|----------|
| `WorkOrderService.php` | `getMaterials()` | BOM 루프 내 `find()` → 배치 사전 로드 |
| `OrderService.php` | `createWorkOrderFromOrder()` | fallback 루프 내 DB 쿼리 → 배치 사전 조회 |
| `OrderService.php` | `checkBendingStockForOrder()` | StockService 루프 호출 → 배치 조회 |
### 4.2 동작 동등성 검증 (수정 전 = 수정 후)
| 수정 | 판정 | 근거 |
|------|:----:|------|
| `getMaterials()` BOM 배치 | **동등** | null 처리, 빈 배열, BOM 없는 경우 모두 동일. `$bomItemsMap[$id] ?? null``find($id)`와 동일한 null 반환 |
| `createWorkOrderFromOrder()` fallback | **동등** | 사전 배치 조회 결과가 즉석 조회와 동일. `DB::transaction` 내부이므로 중간 데이터 변경 없음. 캐시(`codeToIdMap`) 동작도 동일 |
| `checkBendingStockForOrder()` Stock | **동등** | `Stock::whereIn()` 결과가 `StockService::getAvailableStock()` 결과와 동일. `BelongsToTenant` 스코프 + 명시적 `tenant_id` 조건으로 격리 보장 |
### 4.3 엣지 케이스 검증
| 케이스 | 수정 전 | 수정 후 | 동일? |
|--------|--------|--------|:-----:|
| `item_id`가 null인 품목 | `if ($woItem->item_id)` skip | 맵에 포함되지 않아 동일하게 skip | ✅ |
| BOM JSON이 비어있는 품목 | `empty($item->bom)` skip | 동일 | ✅ |
| DB에 없는 `item_code` | `find()` → null | `$map[$code] ?? null` → null | ✅ |
| 재고가 0인 품목 | Stock 없음 → available_qty=0 | `$stocksMap->get($id)` → null → 0 | ✅ |
| 빈 주문 (items 0건) | 루프 미실행 | 배치 조회도 빈 배열, 루프 미실행 | ✅ |
### 4.4 전체 테스트 실행 결과
```
PHPUnit 11.5.27 / PHP 8.4.18
전체: 256개 테스트 실행
통과: 243개
실패: 7개 (모두 수정 전부터 존재하던 기존 문제)
Skip: 6개
이번 수정으로 인한 실패: 0건
```
**실패 7건 상세 (모두 기존 문제):**
| 테스트 | 원인 | 이번 수정과 관계 |
|--------|------|:--------------:|
| `PrefixResolverTest` (1건) | Unit 로직 불일치 (XX vs CF) | 무관 |
| `BendingLotPipelineTest` (3건) | TENANT_ID=287 고정, 로컬 DB 데이터 없음 | 무관 |
| `ItemMasterApiTest` (3건) | `section_id` 컬럼 미존재 (마이그레이션 불일치) | 무관 |
### 4.5 발견된 기존 문제 (수정과 무관, 별도 대응 필요)
`process_items` 테이블 조회에 `tenant_id` 필터가 없다. 수정 전부터 존재하던 문제이며 이번 수정으로 악화되지 않았다. 멀티테넌트 격리가 필요하면 별도 수정이 필요하다.
```php
// OrderService.php — tenant_id 조건 누락 (수정 전/후 동일)
DB::table('process_items')
->whereIn('item_id', $ids)
->where('is_active', true) // tenant_id 없음
->get();
```
### 4.6 결론
**이번 수정으로 기존 API 동작이 깨지는 경우는 없다.** 수정 전과 후의 결과가 정확히 동일하며, 쿼리 수만 줄어든 순수 성능 개선이다.
---
## 테스트 체크리스트
- [x] TestCase 공통 헬퍼 작성 및 기존 11개 테스트 호환 확인
- [x] Factory 5개 생성 (Tenant, Client, Order, Stock, StockLot)
- [x] Order API 테스트 12개 통과
- [x] Stock API + FIFO 테스트 13개 통과
- [x] Approval 워크플로우 테스트 15개 통과
- [x] WorkOrder API 테스트 16개 통과
- [x] N+1 쿼리 3건 배치 조회로 최적화
- [x] 전체 테스트 164개 회귀 없음 확인
- [x] 개발 서버 배포 완료 (2026-03-14)
---
## 관련 문서
- [API 구조 분석 및 개선 로드맵](../../system/api-analysis-report.md) — D1~D8 기술 부채 정의
- [API 개발 규칙](../standards/api-rules.md) — Service-First, FormRequest 컨벤션
- [품질 체크리스트](../standards/quality-checklist.md)
---
**최종 업데이트**: 2026-03-14

View File

@@ -1,186 +1,186 @@
# API 테스트 인프라 정비 및 수주 API 테스트 추가
**날짜:** 2026-03-14
**작업자:** R&D 개발실장 + Claude Code
## 변경 개요
API 프로젝트의 테스트 기반을 체계적으로 정비하고, 미커버 핵심 도메인인 수주(Order) API에 대한 Feature 테스트를 신규 작성했다. 기술 부채 분석(D1: 테스트 커버리지 확충)의 첫 번째 실행 단계이다.
---
## 1. 테스트 인프라 정비
### 1.1 TestCase 공통화
기존 11개 테스트 파일이 동일한 setUp 코드(약 40줄)를 매번 복붙하고 있었다. `tests/TestCase.php`에 공통 메서드를 추가하여 중복을 제거했다.
**추가된 공통 메서드:**
| 메서드 | 용도 |
|--------|------|
| `setUpAuthenticatedUser()` | API Key + Tenant + User + 로그인 토큰 일괄 생성 |
| `api($method, $uri, $data)` | 인증된 API 요청 (X-API-KEY + Bearer 자동 포함) |
| `assertApiSuccess($response)` | 표준 응답 구조 검증 (`success`, `message`, `data`) |
| `assertApiPaginated($response)` | 페이지네이션 응답 구조 검증 |
**Before (각 테스트 파일마다 반복):**
```php
private Tenant $tenant;
private User $user;
private string $apiKey;
private string $token;
protected function setUp(): void {
// 40줄의 동일한 초기화 코드...
}
protected function loginAndGetToken(): void { ... }
protected function authenticatedRequest(...) { ... }
```
**After (한 줄 호출):**
```php
protected function setUp(): void {
parent::setUp();
$this->setUpAuthenticatedUser();
}
// api(), assertApiSuccess() 등 TestCase에서 상속
```
### 1.2 기존 테스트 파일 정리
11개 기존 테스트 파일에서 `private` 프로퍼티 선언, `use DatabaseTransactions`, 중복 헬퍼 메서드를 제거하고 TestCase 상속으로 전환했다.
### 1.3 Factory 신규 생성
기존에 `UserFactory` 1개만 존재했다. 핵심 도메인 테스트에 필요한 Factory 3개를 추가했다.
| Factory | 모델 | 주요 필드 |
|---------|------|----------|
| `TenantFactory` | `Tenant` | company_name, code, email, phone, business_num |
| `ClientFactory` | `Client` | name, client_code, contact_person, phone, business_no |
| `OrderFactory` | `Order` | order_no, order_type_code, status_code, quantity, supply_amount |
`OrderFactory`에는 상태별 빌더 메서드도 포함:
```php
OrderFactory::new()->confirmed() // 확정 상태
OrderFactory::new()->inProduction() // 생산중 상태
OrderFactory::new()->completed() // 완료 상태
OrderFactory::new()->cancelled() // 취소 상태
```
---
## 2. 수주(Order) API 테스트
### 2.1 테스트 목록 (12개)
| 테스트 | 검증 내용 | 결과 |
|--------|----------|:----:|
| `test_수주_목록_조회` | GET `/api/v1/orders` 페이지네이션 응답 | ✅ |
| `test_수주_통계_조회` | GET `/api/v1/orders/stats` 집계 데이터 | ✅ |
| `test_수주_생성_성공` | POST `/api/v1/orders` + items 배열 | ✅ |
| `test_수주_생성_빈_데이터_허용_확인` | 빈 데이터 생성 허용 여부 확인 | ✅ |
| `test_수주_상세_조회` | GET `/api/v1/orders/{id}` 단건 | ✅ |
| `test_존재하지_않는_수주_조회시_404` | 없는 ID 조회 → 404 | ✅ |
| `test_수주_수정_성공` | PUT `/api/v1/orders/{id}` 필드 변경 | ✅ |
| `test_수주_삭제_성공` | DELETE → SoftDelete 확인 | ✅ |
| `test_수주_일괄_삭제` | DELETE `/api/v1/orders/bulk` | ✅ |
| `test_수주_상태_등록에서_확정으로_변경` | PATCH `/{id}/status` DRAFT→CONFIRMED | ✅ |
| `test_수주_상태_취소` | PATCH `/{id}/status` DRAFT→CANCELLED | ✅ |
| `test_미인증_요청시_401` | Bearer 토큰 없이 요청 → 401 | ✅ |
### 2.2 테스트 실행 결과
```
PHPUnit 11.5.27
PHP 8.4.18
전체: 120개 통과, 3개 Skip (기존 라우트 충돌 이슈)
신규: 12개 전부 통과 (46 assertions)
실행 시간: ~8초
```
---
## 3. 발견된 문제
### 3.1 빈 데이터로 수주 생성 허용
```
POST /api/v1/orders (body: {})
→ 200 OK (수주가 생성됨)
```
`StoreOrderRequest`의 검증 규칙이 느슨하여 필수 필드 없이도 수주가 생성된다. FormRequest 검증 강화가 필요하다 (D4 개선 대상).
### 3.2 기존 테스트 실패 (변경 전부터 존재)
| 테스트 | 원인 | 영향 |
|--------|------|------|
| `PrefixResolverTest` | Unit 테스트 로직 불일치 (XX vs CF) | Production 도메인 |
| `BendingLotPipelineTest` (3개) | TENANT_ID=287 고정, 로컬 DB에 해당 데이터 없음 | Production 도메인 |
| `ItemMasterApiTest` (3개) | `section_id` 컬럼 미존재 (마이그레이션 불일치) | ItemMaster 도메인 |
> 이 실패들은 이번 변경과 무관한 기존 문제이다.
---
## 수정된 파일
| 파일 | 변경 내용 |
|------|----------|
| `tests/TestCase.php` | 공통 헬퍼 메서드 4개 추가 (`setUpAuthenticatedUser`, `api`, `assertApiSuccess`, `assertApiPaginated`) |
| `tests/Feature/Account/AccountApiTest.php` | `private` → TestCase 상속, 중복 제거 |
| `tests/Feature/BadDebt/BadDebtApiTest.php` | 동일 |
| `tests/Feature/Category/CategoryApiTest.php` | 동일 |
| `tests/Feature/Company/CompanyApiTest.php` | 동일 |
| `tests/Feature/ItemMaster/ItemMasterApiTest.php` | 동일 |
| `tests/Feature/Payment/PaymentApiTest.php` | 동일 |
| `tests/Feature/Popup/PopupApiTest.php` | 동일 |
| `tests/Feature/Production/BendingLotPipelineTest.php` | `use DatabaseTransactions` 중복 제거 |
| `tests/Feature/Subscription/SubscriptionApiTest.php` | 동일 |
| `tests/Feature/User/NotificationSettingApiTest.php` | 동일 |
| `tests/Feature/User/UserInvitationApiTest.php` | 동일 |
| `database/factories/TenantFactory.php` | **신규** — Tenant 모델 Factory |
| `database/factories/ClientFactory.php` | **신규** — Client 모델 Factory |
| `database/factories/OrderFactory.php` | **신규** — Order 모델 Factory (상태 빌더 포함) |
| `tests/Feature/Orders/OrderApiTest.php` | **신규** — 수주 API 테스트 12개 |
---
## 테스트 체크리스트
- [x] TestCase 공통 헬퍼 작성
- [x] 기존 11개 테스트 파일 중복 제거
- [x] Factory 3개 생성 (Tenant, Client, Order)
- [x] Order API 테스트 12개 작성 및 통과
- [x] 기존 테스트 회귀 없음 확인 (기존 실패는 변경 전부터 존재)
- [ ] StockService 테스트 (다음 단계)
- [ ] ApprovalService 테스트 (다음 단계)
- [ ] WorkOrderService 테스트 (다음 단계)
---
## 다음 단계
기술 부채 D1(테스트 커버리지 확충) 로드맵에 따라 다음 서비스 테스트를 순차 진행한다:
1. **StockService** — 재고 관리 (FIFO, LOT 추적)
2. **ApprovalService** — 전자결재 워크플로우
3. **WorkOrderService** — 작업지시 (가장 큰 서비스, 4,097줄)
---
## 관련 문서
- [API 구조 분석 및 개선 로드맵](../../system/api-analysis-report.md)
- [API 개발 규칙](../standards/api-rules.md)
- [품질 체크리스트](../standards/quality-checklist.md)
---
**최종 업데이트**: 2026-03-14
# API 테스트 인프라 정비 및 수주 API 테스트 추가
**날짜:** 2026-03-14
**작업자:** R&D 개발실장 + Claude Code
## 변경 개요
API 프로젝트의 테스트 기반을 체계적으로 정비하고, 미커버 핵심 도메인인 수주(Order) API에 대한 Feature 테스트를 신규 작성했다. 기술 부채 분석(D1: 테스트 커버리지 확충)의 첫 번째 실행 단계이다.
---
## 1. 테스트 인프라 정비
### 1.1 TestCase 공통화
기존 11개 테스트 파일이 동일한 setUp 코드(약 40줄)를 매번 복붙하고 있었다. `tests/TestCase.php`에 공통 메서드를 추가하여 중복을 제거했다.
**추가된 공통 메서드:**
| 메서드 | 용도 |
|--------|------|
| `setUpAuthenticatedUser()` | API Key + Tenant + User + 로그인 토큰 일괄 생성 |
| `api($method, $uri, $data)` | 인증된 API 요청 (X-API-KEY + Bearer 자동 포함) |
| `assertApiSuccess($response)` | 표준 응답 구조 검증 (`success`, `message`, `data`) |
| `assertApiPaginated($response)` | 페이지네이션 응답 구조 검증 |
**Before (각 테스트 파일마다 반복):**
```php
private Tenant $tenant;
private User $user;
private string $apiKey;
private string $token;
protected function setUp(): void {
// 40줄의 동일한 초기화 코드...
}
protected function loginAndGetToken(): void { ... }
protected function authenticatedRequest(...) { ... }
```
**After (한 줄 호출):**
```php
protected function setUp(): void {
parent::setUp();
$this->setUpAuthenticatedUser();
}
// api(), assertApiSuccess() 등 TestCase에서 상속
```
### 1.2 기존 테스트 파일 정리
11개 기존 테스트 파일에서 `private` 프로퍼티 선언, `use DatabaseTransactions`, 중복 헬퍼 메서드를 제거하고 TestCase 상속으로 전환했다.
### 1.3 Factory 신규 생성
기존에 `UserFactory` 1개만 존재했다. 핵심 도메인 테스트에 필요한 Factory 3개를 추가했다.
| Factory | 모델 | 주요 필드 |
|---------|------|----------|
| `TenantFactory` | `Tenant` | company_name, code, email, phone, business_num |
| `ClientFactory` | `Client` | name, client_code, contact_person, phone, business_no |
| `OrderFactory` | `Order` | order_no, order_type_code, status_code, quantity, supply_amount |
`OrderFactory`에는 상태별 빌더 메서드도 포함:
```php
OrderFactory::new()->confirmed() // 확정 상태
OrderFactory::new()->inProduction() // 생산중 상태
OrderFactory::new()->completed() // 완료 상태
OrderFactory::new()->cancelled() // 취소 상태
```
---
## 2. 수주(Order) API 테스트
### 2.1 테스트 목록 (12개)
| 테스트 | 검증 내용 | 결과 |
|--------|----------|:----:|
| `test_수주_목록_조회` | GET `/api/v1/orders` 페이지네이션 응답 | ✅ |
| `test_수주_통계_조회` | GET `/api/v1/orders/stats` 집계 데이터 | ✅ |
| `test_수주_생성_성공` | POST `/api/v1/orders` + items 배열 | ✅ |
| `test_수주_생성_빈_데이터_허용_확인` | 빈 데이터 생성 허용 여부 확인 | ✅ |
| `test_수주_상세_조회` | GET `/api/v1/orders/{id}` 단건 | ✅ |
| `test_존재하지_않는_수주_조회시_404` | 없는 ID 조회 → 404 | ✅ |
| `test_수주_수정_성공` | PUT `/api/v1/orders/{id}` 필드 변경 | ✅ |
| `test_수주_삭제_성공` | DELETE → SoftDelete 확인 | ✅ |
| `test_수주_일괄_삭제` | DELETE `/api/v1/orders/bulk` | ✅ |
| `test_수주_상태_등록에서_확정으로_변경` | PATCH `/{id}/status` DRAFT→CONFIRMED | ✅ |
| `test_수주_상태_취소` | PATCH `/{id}/status` DRAFT→CANCELLED | ✅ |
| `test_미인증_요청시_401` | Bearer 토큰 없이 요청 → 401 | ✅ |
### 2.2 테스트 실행 결과
```
PHPUnit 11.5.27
PHP 8.4.18
전체: 120개 통과, 3개 Skip (기존 라우트 충돌 이슈)
신규: 12개 전부 통과 (46 assertions)
실행 시간: ~8초
```
---
## 3. 발견된 문제
### 3.1 빈 데이터로 수주 생성 허용
```
POST /api/v1/orders (body: {})
→ 200 OK (수주가 생성됨)
```
`StoreOrderRequest`의 검증 규칙이 느슨하여 필수 필드 없이도 수주가 생성된다. FormRequest 검증 강화가 필요하다 (D4 개선 대상).
### 3.2 기존 테스트 실패 (변경 전부터 존재)
| 테스트 | 원인 | 영향 |
|--------|------|------|
| `PrefixResolverTest` | Unit 테스트 로직 불일치 (XX vs CF) | Production 도메인 |
| `BendingLotPipelineTest` (3개) | TENANT_ID=287 고정, 로컬 DB에 해당 데이터 없음 | Production 도메인 |
| `ItemMasterApiTest` (3개) | `section_id` 컬럼 미존재 (마이그레이션 불일치) | ItemMaster 도메인 |
> 이 실패들은 이번 변경과 무관한 기존 문제이다.
---
## 수정된 파일
| 파일 | 변경 내용 |
|------|----------|
| `tests/TestCase.php` | 공통 헬퍼 메서드 4개 추가 (`setUpAuthenticatedUser`, `api`, `assertApiSuccess`, `assertApiPaginated`) |
| `tests/Feature/Account/AccountApiTest.php` | `private` → TestCase 상속, 중복 제거 |
| `tests/Feature/BadDebt/BadDebtApiTest.php` | 동일 |
| `tests/Feature/Category/CategoryApiTest.php` | 동일 |
| `tests/Feature/Company/CompanyApiTest.php` | 동일 |
| `tests/Feature/ItemMaster/ItemMasterApiTest.php` | 동일 |
| `tests/Feature/Payment/PaymentApiTest.php` | 동일 |
| `tests/Feature/Popup/PopupApiTest.php` | 동일 |
| `tests/Feature/Production/BendingLotPipelineTest.php` | `use DatabaseTransactions` 중복 제거 |
| `tests/Feature/Subscription/SubscriptionApiTest.php` | 동일 |
| `tests/Feature/User/NotificationSettingApiTest.php` | 동일 |
| `tests/Feature/User/UserInvitationApiTest.php` | 동일 |
| `database/factories/TenantFactory.php` | **신규** — Tenant 모델 Factory |
| `database/factories/ClientFactory.php` | **신규** — Client 모델 Factory |
| `database/factories/OrderFactory.php` | **신규** — Order 모델 Factory (상태 빌더 포함) |
| `tests/Feature/Orders/OrderApiTest.php` | **신규** — 수주 API 테스트 12개 |
---
## 테스트 체크리스트
- [x] TestCase 공통 헬퍼 작성
- [x] 기존 11개 테스트 파일 중복 제거
- [x] Factory 3개 생성 (Tenant, Client, Order)
- [x] Order API 테스트 12개 작성 및 통과
- [x] 기존 테스트 회귀 없음 확인 (기존 실패는 변경 전부터 존재)
- [ ] StockService 테스트 (다음 단계)
- [ ] ApprovalService 테스트 (다음 단계)
- [ ] WorkOrderService 테스트 (다음 단계)
---
## 다음 단계
기술 부채 D1(테스트 커버리지 확충) 로드맵에 따라 다음 서비스 테스트를 순차 진행한다:
1. **StockService** — 재고 관리 (FIFO, LOT 추적)
2. **ApprovalService** — 전자결재 워크플로우
3. **WorkOrderService** — 작업지시 (가장 큰 서비스, 4,097줄)
---
## 관련 문서
- [API 구조 분석 및 개선 로드맵](../../system/api-analysis-report.md)
- [API 개발 규칙](../standards/api-rules.md)
- [품질 체크리스트](../standards/quality-checklist.md)
---
**최종 업데이트**: 2026-03-14

File diff suppressed because it is too large Load Diff

View File

@@ -1,242 +1,242 @@
# 바로빌 서비스 출시 단계별 준비 계획
> **작성일**: 2026-03-17
> **상태**: 계획 수립
> **담당**: R&D실
---
## 1. 개요
### 1.1 목적
MNG에서 운영 중인 바로빌 연동 시스템을 서비스(API+React)로 이관하여, 멀티테넌트 고객이 직접 사용할 수 있는 SaaS 형태로 출시한다.
### 1.2 현재 상태
- **MNG (백오피스)**: 바로빌 SOAP 연동 완료, tenant_id=1 (코드브릿지엑스)에서 실무 운영 중
- **API**: DB 모델 15개 + REST API 42개 엔드포인트 구현 완료 (데이터 조회/분개용)
- **React**: 바로빌 설정 페이지 기본 구현
### 1.3 목표
고객(테넌트)이 SAM 서비스에서 바로빌 기능을 직접 설정하고 사용할 수 있도록 한다:
- 계좌조회, 카드내역, 홈택스 세금계산서 자동 수집
- 전자세금계산서 발행
- 카카오톡/SMS 알림
---
## 2. 단계별 로드맵
```
Phase 1 Phase 2 Phase 3 Phase 4
SOAP 이관 UI 구현 베타테스트 정식 출시
(API 개발) (React 개발) (내부→외부) (온보딩 가동)
───────────── → ───────────── → ───────────── → ─────────────
```
---
## 3. Phase 1: SOAP 연동 이관 (API 개발)
> **핵심**: MNG의 BarobillService를 API로 이관하여 멀티테넌트 지원
### 3.1 작업 목록
| # | 작업 | 상세 | 난이도 |
|---|------|------|--------|
| 1-1 | BarobillService 이관 | MNG 1,761줄 → API로 이동, 멀티테넌트 리팩토링 | 상 |
| 1-2 | 회원사 관리 API | 등록/수정/조회/상태확인 엔드포인트 | 중 |
| 1-3 | 인증서 관리 API | 등록URL/유효성/만료일 조회 엔드포인트 | 중 |
| 1-4 | 계좌 관리 API | 등록/목록/입출금 조회 엔드포인트 | 중 |
| 1-5 | 카드 관리 API | 등록/수정/해지/사용내역 조회 엔드포인트 | 중 |
| 1-6 | 세금계산서 발행 API | 작성/발행/조회 엔드포인트 | 상 |
| 1-7 | 동기화 스케줄러 | 은행/카드/홈택스 자동 수집 (Queue Job) | 중 |
| 1-8 | 테스트/운영 모드 전환 API | 회원사별 server_mode 전환 | 하 |
### 3.2 기술 과제
| 과제 | 설명 | 대응 방안 |
|------|------|----------|
| CERTKEY 관리 | 현재 전역 1개 → 멀티테넌트 대응 필요 | 바로빌 파트너 계약 구조 확인 후 결정 |
| PHP SOAP 확장 | API 서버에 `php-soap` 설치 필요 | Docker/서버 환경 확인 |
| 암호화 키 공유 | MNG/API 간 `APP_KEY` 동일해야 복호화 가능 | 현재 동일 키 사용 중 (확인 필요) |
| 동기화 부하 | 테넌트 수 증가 시 SOAP 호출량 증가 | Queue 분산, 호출 간격 조절 |
### 3.3 환경 준비
```bash
# API 서버에 PHP SOAP 확장 확인
php -m | grep soap
# 없으면 설치 (개발 서버 Level 2)
sudo apt install php8.4-soap
sudo systemctl restart php8.4-fpm
# .env 설정 추가
BAROBILL_CERT_KEY_TEST=<테스트 인증키>
BAROBILL_CERT_KEY_PROD=<운영 인증키>
BAROBILL_CORP_NUM=<파트너 사업자번호>
BAROBILL_TEST_MODE=true
```
---
## 4. Phase 2: UI 구현 (React 개발)
> **핵심**: 고객이 직접 바로빌을 설정하고 데이터를 조회할 수 있는 화면
### 4.1 작업 목록
| # | 작업 | 상세 | 난이도 |
|---|------|------|--------|
| 2-1 | 바로빌 설정 페이지 | 회원사 등록/수정, 서버 모드 표시 | 중 |
| 2-2 | 인증서 관리 화면 | 등록 URL 안내, 유효기간 표시, 갱신 알림 | 중 |
| 2-3 | 계좌 관리 화면 | 등록 계좌 목록, 등록 URL 안내 | 중 |
| 2-4 | 카드 관리 화면 | 등록 카드 목록, 추가/해지 | 중 |
| 2-5 | 카드 거래내역 조회 | 기간별 조회, 분개 연동, 숨김/분할 | 상 |
| 2-6 | 은행 거래내역 조회 | 기간별 조회, 분개 연동, 오버라이드/분할 | 상 |
| 2-7 | 홈택스 세금계산서 | 매출/매입 조회, 분개 연동 | 중 |
| 2-8 | 세금계산서 발행 화면 | 작성/발행 폼, 미리보기 | 상 |
### 4.2 화면 구성 (메뉴 구조)
```
재무관리
├─ 계좌관리
│ ├─ 보유계좌 관리 (바로빌 계좌 등록 포함)
│ └─ 계좌 입출금 내역
├─ 카드관리
│ ├─ 법인카드 관리 (바로빌 카드 등록 포함)
│ └─ 카드 사용내역
├─ 세금계산서
│ ├─ 매출 세금계산서
│ ├─ 매입 세금계산서
│ └─ 세금계산서 발행
└─ 설정
└─ 바로빌 연동 설정 (인증서, 모드, 충전잔액)
```
---
## 5. Phase 3: 베타테스트
> **핵심**: 내부 → 외부 순서로 검증, 테스트 모드 사용
### 5.1 내부 베타테스트
| 항목 | 내용 |
|------|------|
| **대상** | tenant_id=1 (코드브릿지엑스 본사) |
| **기간** | 2주 |
| **모드** | 테스트 모드 |
| **검증 항목** | 전체 기능 동작, UI/UX, 데이터 정합성 |
| **비교 기준** | MNG 운영 데이터와 서비스 데이터 일치 확인 |
**내부 베타 체크리스트**:
- [ ] 회원사 등록/수정 정상 동작
- [ ] 인증서 등록 URL 정상 접근
- [ ] 계좌 등록 및 입출금 내역 조회
- [ ] 카드 등록 및 사용내역 조회
- [ ] 홈택스 매출/매입 세금계산서 수집
- [ ] 세금계산서 발행 (테스트 서버)
- [ ] 분개 연동 정상 동작
- [ ] 동기화 스케줄러 자동 수집 확인
- [ ] MNG 데이터와 서비스 데이터 일치
### 5.2 외부 베타테스트
| 항목 | 내용 |
|------|------|
| **대상** | 선별 고객사 2~3곳 |
| **기간** | 2~4주 |
| **모드** | 테스트 모드 |
| **검증 항목** | 실사용 시나리오, 다양한 사업자 유형, 피드백 수집 |
**외부 베타 체크리스트**:
- [ ] 다양한 사업자번호로 회원 등록
- [ ] 다양한 은행/카드사 연동 확인
- [ ] 고객 직접 인증서/계좌/카드 등록 가능 확인
- [ ] 고객 피드백 수집 및 반영
- [ ] 성능 (다수 테넌트 동시 동기화)
---
## 6. Phase 4: 정식 출시
> **핵심**: 운영 모드 전환, 과금 시작, 온보딩 프로세스 가동
### 6.1 출시 준비 체크리스트
**인프라**:
- [ ] API 서버 `php-soap` 확장 설치 확인
- [ ] 운영 `.env``BAROBILL_CERT_KEY_PROD`, `BAROBILL_CORP_NUM` 설정
- [ ] `BAROBILL_TEST_MODE=false` 설정
- [ ] 동기화 스케줄러 Supervisor 등록
- [ ] 바로빌 운영 CERTKEY 충전잔액 확보
**과금**:
- [ ] `barobill_pricing_policies` 요금 정책 데이터 입력
- [ ] 월정액 구독 자동 과금 배치 등록 (매월 1일)
- [ ] 과금 내역 고객 조회 화면 (선택)
**운영**:
- [ ] 인증서 만료 알림 (이메일/카카오톡)
- [ ] 충전잔액 부족 알림
- [ ] 동기화 실패 알림 및 재시도 로직
- [ ] 바로빌 장애 시 대응 매뉴얼
### 6.2 온보딩 프로세스 정립
정식 출시 후 신규 고객 가입 시:
```
계약 → 테넌트 생성 → 회원등록(테스트) → 인증서/계좌/카드 → 검증 → 운영전환 → 실무사용
```
> 상세 프로세스: `features/barobill/tenant-onboarding.md` 참조
---
## 7. 바로빌 파트너 정책 확인 필요 사항
> **경고: 개발 착수 전 바로빌 측에 확인해야 할 사항**
| # | 확인 사항 | 이유 | 현재 상태 |
|---|----------|------|----------|
| 1 | 멀티테넌트 CERTKEY 구조 | 파트너 1개 키로 다수 회원사 관리 가능한지 | 미확인 |
| 2 | 테스트 서버 제한 | 테스트 API 호출 횟수/기간 제한 | 미확인 |
| 3 | 과금 구조 | 파트너 단가표 (건당/월정액) | 미확인 |
| 4 | SLA | 바로빌 API 가용성 보장 수준 | 미확인 |
| 5 | 회원사 대량 등록 | 일괄 등록 API 또는 제한 | 미확인 |
| 6 | 인증서 대리 등록 | 고객 대신 등록 가능 여부 | 미확인 |
---
## 8. 리스크 및 대응
| 리스크 | 영향 | 대응 |
|--------|------|------|
| 바로빌 API 장애 | 거래 데이터 수집 중단 | 재시도 로직 + 장애 알림 |
| 인증서 만료 | 계좌/세금계산서 조회 불가 | 만료 30일 전 알림 |
| SOAP 호출 지연 | 페이지 응답 지연 | 비동기 Queue 처리 |
| 테넌트 급증 | 동기화 부하 | 호출 간격 분산, 우선순위 큐 |
| 충전잔액 부족 | API 호출 실패 | 잔액 모니터링 + 자동 알림 |
---
## 관련 문서
| 문서 | 설명 |
|------|------|
| [바로빌 연동 시스템](../../features/barobill/README.md) | 전체 구조, 모드, 과금 |
| [테넌트 온보딩](../../features/barobill/tenant-onboarding.md) | 온보딩 6단계 프로세스 |
| [바로빌 API 명세](../../frontend/api-specs/barobill-api.md) | REST API 42개 엔드포인트 |
| [이관 현황](../../system/migration-status.md) | MNG→API+React 전체 이관 현황 |
---
**최종 업데이트**: 2026-03-17
# 바로빌 서비스 출시 단계별 준비 계획
> **작성일**: 2026-03-17
> **상태**: 계획 수립
> **담당**: R&D실
---
## 1. 개요
### 1.1 목적
MNG에서 운영 중인 바로빌 연동 시스템을 서비스(API+React)로 이관하여, 멀티테넌트 고객이 직접 사용할 수 있는 SaaS 형태로 출시한다.
### 1.2 현재 상태
- **MNG (백오피스)**: 바로빌 SOAP 연동 완료, tenant_id=1 (코드브릿지엑스)에서 실무 운영 중
- **API**: DB 모델 15개 + REST API 42개 엔드포인트 구현 완료 (데이터 조회/분개용)
- **React**: 바로빌 설정 페이지 기본 구현
### 1.3 목표
고객(테넌트)이 SAM 서비스에서 바로빌 기능을 직접 설정하고 사용할 수 있도록 한다:
- 계좌조회, 카드내역, 홈택스 세금계산서 자동 수집
- 전자세금계산서 발행
- 카카오톡/SMS 알림
---
## 2. 단계별 로드맵
```
Phase 1 Phase 2 Phase 3 Phase 4
SOAP 이관 UI 구현 베타테스트 정식 출시
(API 개발) (React 개발) (내부→외부) (온보딩 가동)
───────────── → ───────────── → ───────────── → ─────────────
```
---
## 3. Phase 1: SOAP 연동 이관 (API 개발)
> **핵심**: MNG의 BarobillService를 API로 이관하여 멀티테넌트 지원
### 3.1 작업 목록
| # | 작업 | 상세 | 난이도 |
|---|------|------|--------|
| 1-1 | BarobillService 이관 | MNG 1,761줄 → API로 이동, 멀티테넌트 리팩토링 | 상 |
| 1-2 | 회원사 관리 API | 등록/수정/조회/상태확인 엔드포인트 | 중 |
| 1-3 | 인증서 관리 API | 등록URL/유효성/만료일 조회 엔드포인트 | 중 |
| 1-4 | 계좌 관리 API | 등록/목록/입출금 조회 엔드포인트 | 중 |
| 1-5 | 카드 관리 API | 등록/수정/해지/사용내역 조회 엔드포인트 | 중 |
| 1-6 | 세금계산서 발행 API | 작성/발행/조회 엔드포인트 | 상 |
| 1-7 | 동기화 스케줄러 | 은행/카드/홈택스 자동 수집 (Queue Job) | 중 |
| 1-8 | 테스트/운영 모드 전환 API | 회원사별 server_mode 전환 | 하 |
### 3.2 기술 과제
| 과제 | 설명 | 대응 방안 |
|------|------|----------|
| CERTKEY 관리 | 현재 전역 1개 → 멀티테넌트 대응 필요 | 바로빌 파트너 계약 구조 확인 후 결정 |
| PHP SOAP 확장 | API 서버에 `php-soap` 설치 필요 | Docker/서버 환경 확인 |
| 암호화 키 공유 | MNG/API 간 `APP_KEY` 동일해야 복호화 가능 | 현재 동일 키 사용 중 (확인 필요) |
| 동기화 부하 | 테넌트 수 증가 시 SOAP 호출량 증가 | Queue 분산, 호출 간격 조절 |
### 3.3 환경 준비
```bash
# API 서버에 PHP SOAP 확장 확인
php -m | grep soap
# 없으면 설치 (개발 서버 Level 2)
sudo apt install php8.4-soap
sudo systemctl restart php8.4-fpm
# .env 설정 추가
BAROBILL_CERT_KEY_TEST=<테스트 인증키>
BAROBILL_CERT_KEY_PROD=<운영 인증키>
BAROBILL_CORP_NUM=<파트너 사업자번호>
BAROBILL_TEST_MODE=true
```
---
## 4. Phase 2: UI 구현 (React 개발)
> **핵심**: 고객이 직접 바로빌을 설정하고 데이터를 조회할 수 있는 화면
### 4.1 작업 목록
| # | 작업 | 상세 | 난이도 |
|---|------|------|--------|
| 2-1 | 바로빌 설정 페이지 | 회원사 등록/수정, 서버 모드 표시 | 중 |
| 2-2 | 인증서 관리 화면 | 등록 URL 안내, 유효기간 표시, 갱신 알림 | 중 |
| 2-3 | 계좌 관리 화면 | 등록 계좌 목록, 등록 URL 안내 | 중 |
| 2-4 | 카드 관리 화면 | 등록 카드 목록, 추가/해지 | 중 |
| 2-5 | 카드 거래내역 조회 | 기간별 조회, 분개 연동, 숨김/분할 | 상 |
| 2-6 | 은행 거래내역 조회 | 기간별 조회, 분개 연동, 오버라이드/분할 | 상 |
| 2-7 | 홈택스 세금계산서 | 매출/매입 조회, 분개 연동 | 중 |
| 2-8 | 세금계산서 발행 화면 | 작성/발행 폼, 미리보기 | 상 |
### 4.2 화면 구성 (메뉴 구조)
```
재무관리
├─ 계좌관리
│ ├─ 보유계좌 관리 (바로빌 계좌 등록 포함)
│ └─ 계좌 입출금 내역
├─ 카드관리
│ ├─ 법인카드 관리 (바로빌 카드 등록 포함)
│ └─ 카드 사용내역
├─ 세금계산서
│ ├─ 매출 세금계산서
│ ├─ 매입 세금계산서
│ └─ 세금계산서 발행
└─ 설정
└─ 바로빌 연동 설정 (인증서, 모드, 충전잔액)
```
---
## 5. Phase 3: 베타테스트
> **핵심**: 내부 → 외부 순서로 검증, 테스트 모드 사용
### 5.1 내부 베타테스트
| 항목 | 내용 |
|------|------|
| **대상** | tenant_id=1 (코드브릿지엑스 본사) |
| **기간** | 2주 |
| **모드** | 테스트 모드 |
| **검증 항목** | 전체 기능 동작, UI/UX, 데이터 정합성 |
| **비교 기준** | MNG 운영 데이터와 서비스 데이터 일치 확인 |
**내부 베타 체크리스트**:
- [ ] 회원사 등록/수정 정상 동작
- [ ] 인증서 등록 URL 정상 접근
- [ ] 계좌 등록 및 입출금 내역 조회
- [ ] 카드 등록 및 사용내역 조회
- [ ] 홈택스 매출/매입 세금계산서 수집
- [ ] 세금계산서 발행 (테스트 서버)
- [ ] 분개 연동 정상 동작
- [ ] 동기화 스케줄러 자동 수집 확인
- [ ] MNG 데이터와 서비스 데이터 일치
### 5.2 외부 베타테스트
| 항목 | 내용 |
|------|------|
| **대상** | 선별 고객사 2~3곳 |
| **기간** | 2~4주 |
| **모드** | 테스트 모드 |
| **검증 항목** | 실사용 시나리오, 다양한 사업자 유형, 피드백 수집 |
**외부 베타 체크리스트**:
- [ ] 다양한 사업자번호로 회원 등록
- [ ] 다양한 은행/카드사 연동 확인
- [ ] 고객 직접 인증서/계좌/카드 등록 가능 확인
- [ ] 고객 피드백 수집 및 반영
- [ ] 성능 (다수 테넌트 동시 동기화)
---
## 6. Phase 4: 정식 출시
> **핵심**: 운영 모드 전환, 과금 시작, 온보딩 프로세스 가동
### 6.1 출시 준비 체크리스트
**인프라**:
- [ ] API 서버 `php-soap` 확장 설치 확인
- [ ] 운영 `.env``BAROBILL_CERT_KEY_PROD`, `BAROBILL_CORP_NUM` 설정
- [ ] `BAROBILL_TEST_MODE=false` 설정
- [ ] 동기화 스케줄러 Supervisor 등록
- [ ] 바로빌 운영 CERTKEY 충전잔액 확보
**과금**:
- [ ] `barobill_pricing_policies` 요금 정책 데이터 입력
- [ ] 월정액 구독 자동 과금 배치 등록 (매월 1일)
- [ ] 과금 내역 고객 조회 화면 (선택)
**운영**:
- [ ] 인증서 만료 알림 (이메일/카카오톡)
- [ ] 충전잔액 부족 알림
- [ ] 동기화 실패 알림 및 재시도 로직
- [ ] 바로빌 장애 시 대응 매뉴얼
### 6.2 온보딩 프로세스 정립
정식 출시 후 신규 고객 가입 시:
```
계약 → 테넌트 생성 → 회원등록(테스트) → 인증서/계좌/카드 → 검증 → 운영전환 → 실무사용
```
> 상세 프로세스: `features/barobill/tenant-onboarding.md` 참조
---
## 7. 바로빌 파트너 정책 확인 필요 사항
> **경고: 개발 착수 전 바로빌 측에 확인해야 할 사항**
| # | 확인 사항 | 이유 | 현재 상태 |
|---|----------|------|----------|
| 1 | 멀티테넌트 CERTKEY 구조 | 파트너 1개 키로 다수 회원사 관리 가능한지 | 미확인 |
| 2 | 테스트 서버 제한 | 테스트 API 호출 횟수/기간 제한 | 미확인 |
| 3 | 과금 구조 | 파트너 단가표 (건당/월정액) | 미확인 |
| 4 | SLA | 바로빌 API 가용성 보장 수준 | 미확인 |
| 5 | 회원사 대량 등록 | 일괄 등록 API 또는 제한 | 미확인 |
| 6 | 인증서 대리 등록 | 고객 대신 등록 가능 여부 | 미확인 |
---
## 8. 리스크 및 대응
| 리스크 | 영향 | 대응 |
|--------|------|------|
| 바로빌 API 장애 | 거래 데이터 수집 중단 | 재시도 로직 + 장애 알림 |
| 인증서 만료 | 계좌/세금계산서 조회 불가 | 만료 30일 전 알림 |
| SOAP 호출 지연 | 페이지 응답 지연 | 비동기 Queue 처리 |
| 테넌트 급증 | 동기화 부하 | 호출 간격 분산, 우선순위 큐 |
| 충전잔액 부족 | API 호출 실패 | 잔액 모니터링 + 자동 알림 |
---
## 관련 문서
| 문서 | 설명 |
|------|------|
| [바로빌 연동 시스템](../../features/barobill/README.md) | 전체 구조, 모드, 과금 |
| [테넌트 온보딩](../../features/barobill/tenant-onboarding.md) | 온보딩 6단계 프로세스 |
| [바로빌 API 명세](../../frontend/api-specs/barobill-api.md) | REST API 42개 엔드포인트 |
| [이관 현황](../../system/migration-status.md) | MNG→API+React 전체 이관 현황 |
---
**최종 업데이트**: 2026-03-17

View File

@@ -3,7 +3,8 @@
> **시작일**: 2026-03-16
> **위치**: MNG 생산관리 > 절곡품 관리 (신규 메뉴)
> **목표**: 경동기업(5130) 수준의 절곡품 마스터 관리 + 전개도 데이터 + 이미지 관리
> **원칙**: 기존 BendingInfoBuilder/PrefixResolver 보존, items.options 확장 방식
> **원칙**: 기존 BendingInfoBuilder/PrefixResolver 보존, **전용 테이블 분리 방식**
> **최종수정**: 2026-03-19 (테이블 분리 완료, 데이터 이관 완료)
---
@@ -16,6 +17,340 @@ SAM은 절곡품의 "계산과 조합"(BendingInfoBuilder/PrefixResolver)은 잘
---
## ⚠️ 아키텍처 변경: items → 전용 테이블 분리 (2026-03-19)
### 변경 결정 배경
기존에는 `items` 테이블(`item_category='BENDING'`)에 `options` JSON으로 절곡 속성을 저장했으나,
다음 문제로 **전용 테이블 분리**로 방향 전환:
| 문제 | 설명 |
|------|------|
| **검색 불가** | 레거시 5130에서 "하장바" 검색 시 5건+ 나오지만 MNG2 기초관리에서 0건 |
| **options 누락** | BD-LEGACY-* 210건 중 상당수가 `options.item_name` 미채워짐 → 검색 불가 |
| **JSON 비정규화** | 20+개 절곡 속성이 options JSON 안에 있어 인덱싱/검색/정렬 불가 |
| **코드 체계 불일치** | BD-LEGACY-*, BD-{품명}-* 혼재, LOT 코드 체계 적용 불가 |
| **스키마 불명확** | options 키가 코드에만 정의(OPTION_KEYS), DB 레벨 제약 없음 |
### 하장바 검색 문제 — ✅ 해결됨
```
[레거시 5130] chandj.bending WHERE item_name LIKE '%하장바%'
→ 13건 (삭제 3건 제외 = 유효 10건)
[이전 MNG2 — items 방식] items WHERE item_category='BENDING' AND name LIKE '%하장바%'
→ 2건만 (options.item_name 누락 → 검색 불가)
[현재 MNG2 — bending_items 전용 테이블] bending_items WHERE item_name LIKE '%하장바%'
→ 10건 ✅ (정규 컬럼 item_name에 인덱스, chandj 유효건과 일치)
[해결 방법]
테이블 분리(bending_items) + bending:clean-reimport로 chandj.bending 직접 이관
→ item_name이 정규 컬럼으로 승격되어 검색 정상 동작
```
### 새 테이블 구조: `bending_items`
```sql
CREATE TABLE bending_items (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
-- 코드 체계 (LOT 코드 = 제품Code + 종류Code + YYMMDD)
code VARCHAR(50) NOT NULL, -- LOT: {제품}{종류}{YYMMDD} (예: CP260319 = 케이스 점검구)
legacy_code VARCHAR(50) NULL, -- 이전 BD-LEGACY-* / BD-{품명}-* 코드
legacy_bending_id INT UNSIGNED NULL, -- chandj.bending.id 참조
-- 기본 정보 (기존 options에서 정규 컬럼으로 승격)
item_name VARCHAR(100) NOT NULL, -- 품명 (검색 가능!)
item_sep VARCHAR(20) NULL, -- 대분류: 스크린/철재
item_bending VARCHAR(50) NULL, -- 중분류: 가이드레일/케이스/하단마감재/...
material VARCHAR(50) NULL, -- 재질: SUS 1.2T / EGI 1.55T
item_spec VARCHAR(100) NULL, -- 규격: 120*70
model_name VARCHAR(50) NULL, -- 소속 모델: KSS01
model_UA VARCHAR(20) NULL, -- 인정여부: 인정/비인정
-- 절곡 전용 속성
rail_width DECIMAL(10,2) NULL, -- 레일폭
exit_direction VARCHAR(20) NULL, -- 출구방향 (케이스 전용)
box_width DECIMAL(10,2) NULL, -- 박스폭 (케이스 전용)
box_height DECIMAL(10,2) NULL, -- 박스높이 (케이스 전용)
front_bottom DECIMAL(10,2) NULL, -- 전면밑 (케이스 전용)
inspection_door VARCHAR(20) NULL, -- 점검구 (케이스 전용)
-- 메타 (비정형 속성만 — 검색/필터 대상 아닌 것)
options JSON NULL, -- memo, author, search_keyword, modified_by 등
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by BIGINT UNSIGNED NULL,
updated_by BIGINT UNSIGNED NULL,
deleted_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
-- 인덱스
INDEX idx_tenant (tenant_id),
INDEX idx_item_name (item_name),
INDEX idx_item_sep (item_sep),
INDEX idx_item_bending (item_bending),
INDEX idx_material (material),
INDEX idx_model_name (model_name),
INDEX idx_code (code),
INDEX idx_legacy_code (legacy_code),
UNIQUE KEY uk_tenant_code (tenant_id, code, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 전개도 데이터: `bending_items.bending_data` (JSON 컬럼)
> **변경 이력**: 초기 설계는 별도 `bending_data` 테이블이었으나, JSON 통합으로 최종 결정.
> 마이그레이션 `100007_move_bending_data_back_to_json`으로 `bending_data` 테이블 DROP 완료.
```json
// bending_items.bending_data JSON 구조
[
{ "no": 1, "input": 10, "rate": "", "sum": 10, "color": true, "aAngle": false },
{ "no": 2, "input": 11, "rate": "", "sum": 21, "color": false, "aAngle": false },
{ "no": 3, "input": 110, "rate": "-1", "sum": 130, "color": false, "aAngle": false }
]
```
### 테이블 관계도 (최종)
```
┌──────────────────────────┐ ┌──────────────────────┐
│ bending_items (266건) │ │ bending_models (62건) │
│ ──────────────────────── │ │ ──────────────────── │
│ 기초관리 마스터 │ │ 가이드레일/케이스/ │
│ 품명/재질/규격 (정규컬럼) │◄···│ 하단마감재 모델 │
│ bending_data: JSON (내장) │ │ components JSON │
│ code: RM260319 등 │ │ (sam_item_id 참조) │
└──────────┬───────────────┘ └──────────────────────┘
│ 코드 매핑 (FK 없음)
│ code 앞 2자리로 items.code 패턴 매칭
┌──────────────────────┐
│ items (기존 무변경) │
│ ──────────────────── │
│ BD-{PREFIX}-{LENGTH} │
│ 재고/BOM/작업지시서 │
│ BendingInfoBuilder │
└──────────────────────┘
```
### items 테이블과의 관계 — FK 없음, 코드 매핑
```
┌──────────────────────────┐ ┌──────────────────────────┐
│ bending_items (신규) │ │ items (기존 유지) │
│ ──────────────────────── │ │ ──────────────────────── │
│ 기초관리 마스터 전용 │ 코드 │ BD-{prod}{spec}-{length} │
│ 품명/재질/규격 정규 컬럼 │ ·····→ │ 재고/BOM/작업지시서 연결 │
│ 전개도 데이터 │ 매핑 │ item_category='BENDING' │
│ code: CP260319 등 │ │ 재고관리용 (무변경) │
│ bending_item_mappings 흡수│ │ │
└──────────────────────────┘ └──────────────────────────┘
연결 방식: FK 없음
- 재고 조회 필요 시: code 앞 2자리(제품+종류)로 items.code 패턴 매칭
- items 테이블의 재고/BOM 기능은 완전히 독립 유지
- BendingInfoBuilder는 items 테이블 계속 참조 (무변경)
- bending_item_mappings 테이블 → 제거 (code에 흡수)
```
### 영향도 분석 — 변경 / 무변경 구분
| 컴포넌트 | 변경 여부 | 설명 |
|----------|----------|------|
| `bending_items` 테이블 | **신규 생성** | 전용 테이블 + mappings 흡수 |
| `bending_item_mappings` 테이블 | **제거** | bending_items에 컬럼 흡수 |
| `BendingItemService` | **수정** | Item::where(BENDING) → BendingItem 모델 |
| `BendingItemResource` | **수정** | getOption() → 정규 컬럼 직접 참조 |
| `BendingItemController` (API) | 최소 수정 | 서비스만 바뀜, 라우트 동일 |
| `BendingItemMapping` 모델 | **제거** | |
| `BendingBaseController` (MNG) | 무변경 | API 클라이언트, URL 동일 |
| `BendingProductController` (MNG) | 무변경 | API 클라이언트 |
| `BendingInfoBuilder` | **무변경** | items 테이블 기반 (재고/BOM용) |
| `Migrate5130BendingStock` | **무변경** | items 테이블 재고 생성용 |
| `ValidateBendingItems` | **무변경** | items의 BD-* 재고 검증용 |
| `files` (이미지) | **수정** | fileable_type='BendingItem' |
| `work_order_items` | 무변경 | items.id 참조 유지 |
### 수정 대상 파일 및 완료 상태 (2026-03-19)
```
[API 프로젝트] /home/kkk/sam/api/
✅ 기초관리 (bending_items):
app/Models/BendingItem.php ← Eloquent (bending_data JSON, files)
app/Services/BendingItemService.php ← BendingItem 모델, JSON 직접 저장
app/Services/BendingCodeService.php ← BendingItem 조회 (LOT 코드)
app/Http/Resources/Api/V1/BendingItemResource.php ← 정규 컬럼 + bending_data JSON + 수치 int 캐스팅
app/Http/Requests/Api/V1/BendingItemStoreRequest.php ← unique → bending_items
app/Swagger/v1/BendingItemApi.php ← Swagger 스키마
✅ 절곡품 모델 (bending_models):
app/Models/BendingModel.php ← Eloquent (components JSON, files)
app/Services/GuiderailModelService.php ← BendingModel + component 이미지 자동 복사
app/Http/Resources/Api/V1/GuiderailModelResource.php ← 정규 컬럼 + 수치 int 캐스팅
✅ 파일 처리:
app/Http/Controllers/Api/V1/ItemsFileController.php ← items → bending_items → bending_models 폴백
✅ 이관 커맨드:
app/Console/Commands/BendingCleanReimport.php ← 기초관리 클린 재이관 + 이미지 (1커맨드)
app/Console/Commands/BendingModelImport.php ← 모델 이관 + 조립도 JSON 업로드 + component 이미지 복사 (1커맨드)
✅ 마이그레이션:
2026_03_19_100000_create_bending_items_table.php
2026_03_19_100001~100003 (bending_data 테이블 → JSON 통합 과정)
2026_03_19_100004_drop_bending_item_mappings_table.php
2026_03_19_100005_add_length_columns_to_bending_items.php
2026_03_19_100006_create_bending_models_table.php
2026_03_19_100007_move_bending_data_back_to_json.php
✅ 제거:
app/Models/Production/BendingItemMapping.php ← 삭제됨
app/Models/BendingDataRow.php ← 삭제됨 (JSON 통합)
bending_item_mappings 테이블 ← DROP
bending_data 테이블 ← DROP (JSON 통합)
무변경:
app/Http/Controllers/Api/V1/BendingItemController.php ← 서비스 주입 (변경 불필요)
app/Http/Controllers/Api/V1/GuiderailModelController.php ← 서비스 주입
app/Services/Production/BendingInfoBuilder.php ← items 직접 사용 (재고/BOM)
app/Console/Commands/Migrate5130BendingStock.php ← items 재고용
app/Console/Commands/ValidateBendingItems.php ← items 재고 검증용
```
### 전체 복원 커맨드
```bash
# Step 1: 기초관리 (265건 + bending_data JSON + 이미지 265건)
php artisan bending:clean-reimport --legacy-img-path=/tmp/bending_img
# Step 2: 절곡품 모델 (61건 + 조립도 61건 + 부품이미지 276건 + sam_item_id)
php artisan bending:model-import --legacy-path=/tmp/legacy_5130
# 사전 준비 (docker 컨테이너에 레거시 파일 복사):
docker cp /home/kkk/sam/5130/bending/img docker-api-1:/tmp/bending_img
docker compose exec -T api mkdir -p /tmp/legacy_5130
docker cp /home/kkk/sam/5130/guiderail docker-api-1:/tmp/legacy_5130/guiderail
docker cp /home/kkk/sam/5130/shutterbox docker-api-1:/tmp/legacy_5130/shutterbox
docker cp /home/kkk/sam/5130/bottombar docker-api-1:/tmp/legacy_5130/bottombar
```
### 데이터 현황 (2026-03-19 DB 검증 완료)
| 테이블 | 건수 | 소스 | 상태 |
|--------|------|------|------|
| bending_items | **266건** | chandj.bending 직접 (bending_data JSON 포함) | ✅ 이관 완료 |
| bending_models | **62건** | chandj guiderail 21 + shutterbox 30 + bottombar 11 | ✅ 이관 완료 |
| bending_item_mappings | 0건 | **DROP 완료** | ✅ 제거됨 |
| items (BENDING) | 215건 | 기존 재고/BOM용 — **무변경 유지** | ✅ 독립 |
| 파일 (R2 업로드) | 예정 건수 | 현재 | 비고 |
|------------------|----------|------|------|
| bending_item / bending_diagram | 266건 | ⬜ 미업로드 | `bending:clean-reimport --legacy-img-path` 필요 |
| bending_model / assembly_image | 62건 | ⬜ 미업로드 | `bending:model-import --legacy-path` 필요 |
| bending_model / component_image | ~280건 | ⬜ 미업로드 | 부품별 독립 복사본 (스냅샷) |
| 레거시 대비 | chandj | bending_models | 상태 |
|-------------|--------|----------------|------|
| 가이드레일 | 20건 | 21건 | ✅ |
| 케이스 | 30건 | 30건 | ✅ |
| 하단마감재(스크린) | 8건 | 8건 | ✅ |
| 하단마감재(철재) | 3건 | 3건 | ✅ |
| 검색 검증 | 이전(items) | 현재(bending_items) | 상태 |
|----------|------------|-------------------|------|
| 하장바 | 2건 | **10건** (chandj 유효건 일치) | ✅ 해결 |
> **이미지 업로드 안내**: 레거시 이미지 파일을 docker 컨테이너에 복사 후 artisan 커맨드 실행 필요 (위 "전체 복원 커맨드" 참조)
### 이미지 스냅샷 정책
```
기초관리 이미지 수정 → 모델 component에 영향 없음 (독립 복사본)
구조:
bending_items → files (bending_diagram) ← 원본 (수정 가능)
bending_models → components[].image_file_id ← 복사본 (독립)
→ files (assembly_image) ← 조립도 (별도)
신규 부품 추가 시:
API(GuiderailModelService)에서 image_file_id 자동 복사 예정
MNG2 editPartOriginal() → sam_item_id로 기초관리 편집 페이지 연결
```
### LOT 코드 체계
```
형식: {제품Code}{종류Code}{YYMMDD}
유니크: (tenant_id, code, length_code, deleted_at)
예시:
RS260319 + length_code=30 → 가이드레일(벽면) SUS마감 3000mm
CF260319 → 케이스 전면부
BS260319 + length_code=40 → 하단마감재(스크린) SUS 4000mm
변환 완료: BD-PREFIX-LEN 112건 → LOT 코드
미변환: BD-한글 58건, BD-LEGACY 40건 (legacy_code 유지, 향후 변환)
```
### LOT 코드 체계 (레거시 형태 유지)
```
형식: {제품Code}{종류Code}{YYMMDD}
예시:
RM260319 → 가이드레일(벽면형) 본체, 2026-03-19
RS260319 → 가이드레일(벽면형) SUS마감재, 2026-03-19
CF260319 → 케이스 전면부, 2026-03-19
BS260319 → 하단마감재(스크린) SUS, 2026-03-19
```
**LOT 코드 테이블 (정본)**:
| 제품 | 제품Code | 종류명 | 종류Code |
|------|----------|--------|----------|
| 가이드레일(벽면형) | R | 본체 | M |
| | | 본체(철재) | T |
| | | C형 | C |
| | | D형 | D |
| | | SUS 마감재 | S |
| 가이드레일(측면형) | S | 본체디딤 | M |
| | | 본체(철재) | T |
| | | C형 | C |
| | | D형 | D |
| | | SUS 마감재1 | S |
| | | SUS 마감재2 | U |
| 케이스 | C | 전면부 | F |
| | | 점검구 | P |
| | | 린텔부 | L |
| | | 후면코너부 | B |
| 하단마감재(스크린) | B | SUS | S |
| | | EGI | E |
| 하단마감재(철재) | T | SUS | S |
| | | EGI | E |
| L-Bar | L | 스크린용 | A |
| 연기차단재 | G | 화이바원단(W50) | I |
| | | 화이바원단(W80) | I |
### 데이터 현황 (2026-03-19 DB 검증 완료)
| 항목 | 건수 | 비고 |
|------|------|------|
| **bending_items** (전용 테이블) | **266건** | ✅ 전건 bending_data JSON 포함 |
| **bending_models** (전용 테이블) | **62건** | ✅ guiderail 21 + shutterbox 30 + bottombar 11 |
| items BENDING (기존, 무변경) | 215건 | 재고/BOM용 독립 유지 |
| bending_item_mappings | **DROP 완료** | bending_items.code에 흡수 |
| 하장바 (bending_items) | **10건** | ✅ chandj 유효건과 일치 |
| 이미지 (R2) | **미업로드** | 레거시 파일 docker 복사 후 커맨드 실행 필요 |
---
## MNG 현재 구조
### 생산관리 메뉴 (sidebar-static.blade.php)
@@ -47,6 +382,10 @@ SAM은 절곡품의 "계산과 조합"(BendingInfoBuilder/PrefixResolver)은 잘
```
Step 1 (DB분석) → Step 2 (API) → Step 3 (MNG 화면) → Step 4 (React 연동)
✅ 완료 ✅ 완료 ✅ 완료 (샘플용) ⬜ 미착수
테이블 분리: ✅ 완료 (bending_items + bending_models 전용 테이블)
데이터 이관: ✅ 완료 (266건 기초관리 + 62건 모델)
이미지 업로드: ⬜ 미완료 (레거시 파일 docker 복사 후 커맨드 실행 필요)
```
| 문서 | 내용 | 상태 |
@@ -55,19 +394,23 @@ Step 1 (DB분석) → Step 2 (API) → Step 3 (MNG 화면) → Step 4 (React
| `step2-API.md` | API 엔드포인트 + 컨트롤러 설계 | ✅ 완료 |
| `step3-MNG화면.md` | Blade 뷰 + HTMX + 메뉴 등록 | ✅ 완료 |
| `step4-React연동.md` | React 운영 화면 구현 | ⬜ 미착수 |
| `legacy-guiderail-analysis.md` | 레거시 guiderail 모듈 상세 분석 | ✅ 완료 |
### 완료된 작업 (2026-03-16~17)
### 완료된 작업 (2026-03-16~19)
**Step 1 완료:**
- `bending:fill-options` — BD-* prefix/분류 속성 자동 보강 (170건)
- `bending:import-legacy` — chandj 전개도(bendingData) 임포트 (139/170건)
- `guiderail:import-legacy` — chandj guiderail 20건 임포트
- `bending-product:import-legacy` — chandj shutterbox 30건 + bottombar 10건 임포트
**Step 1 완료 (DB 분석 + 테이블 분리):**
- `bending_items` 전용 테이블 생성 — 정규 컬럼 승격 (item_name, item_sep, material 등 인덱스)
- `bending_models` 전용 테이블 생성 — 가이드레일/케이스/하단마감재 3개 타입 통합
- `bending_data` 테이블 → JSON 통합 → `bending_items.bending_data` 컬럼
- `bending_item_mappings` 테이블 DROP — `bending_items.code`에 흡수
- `bending:clean-reimport` — chandj.bending 266건 직접 이관 (bending_data JSON 포함)
- `bending:model-import` — chandj guiderail 21 + shutterbox 30 + bottombar 11 = 62건 이관
- ~~`bending:fill-options`~~ / ~~`bending:import-legacy`~~ — 구 items 방식 커맨드 (대체됨)
**Step 2 완료:**
**Step 2 완료 (API):**
- `BendingItemController` — CRUD + filters + pagination (6 엔드포인트)
- `GuiderailModelController` — CRUD + filters (6 엔드포인트, 3개 카테고리 통합)
- `BendingItemResource` / `GuiderailModelResource` — API 응답 포맷
- `BendingItemResource` / `GuiderailModelResource` — API 응답 포맷 (정규 컬럼 직접 참조)
- `FormRequest` — Index/Store/Update 유효성 검증
- `ApiKeyMiddleware` — bending/guiderail/files 화이트리스트
@@ -79,6 +422,10 @@ Step 1 (DB분석) → Step 2 (API) → Step 3 (MNG 화면) → Step 4 (React
- 파일: FileViewController (API R2 프록시) + 이미지 업로드/표시
- DB 메뉴: 기초관리 + 절곡품 + 케이스 + 하단마감재 (4개)
**미완료:**
- ⬜ 이미지 R2 업로드 — 레거시 파일 docker 복사 후 커맨드 재실행 필요
- ⬜ Step 4 React 연동 — 미착수
---
## 참조 문서
@@ -121,11 +468,13 @@ Step 1 (DB분석) → Step 2 (API) → Step 3 (MNG 화면) → Step 4 (React
│ │ Blade │ │ Laravel │ │ Next.js │ │
│ └──────────┘ └─────┬────┘ └──────────┘ │
│ │ │
│ ┌────────┐
│ │ samdb
│ │ items │ ← item_category = 'BENDING'
│ │ files │ ← field_key = 'bending_diagram'
└─────────┘
│ ┌─────────────┐ │
│ │ samdb
│ │bending_items ← 기초관리 마스터 (전용, bending_data JSON 포함)
│ │bending_models│ ← 절곡품 모델 (가이드레일/케이스/하단마감재)
│ items ← 재고/BOM용 (기존 무변경)
│ │ files │ ← bending_diagram 이미지 │
│ └─────────────┘ │
│ │ │
│ ┌────┴────┐ │
│ │ R2 │ ← Cloudflare (이미지 저장) │
@@ -136,37 +485,34 @@ Step 1 (DB분석) → Step 2 (API) → Step 3 (MNG 화면) → Step 4 (React
---
## 2. 데이터 구조 (2계층)
## 2. 데이터 구조 (2계층 — 전용 테이블 + JSON)
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ [1계층] 기초관리 — 개별 부품 (items 테이블)
│ ════════════════════════════════════════
│ [1계층] 기초관리 — 개별 부품 (bending_items 전용 테이블) │
│ ══════════════════════════════════════════════
│ │
items (item_category = 'BENDING')
bending_items (266건)
│ ┌──────────────────────────────────────────────────┐ │
│ │ id: 100 │ │
│ │ code: BD-가이드레일-KSS01-SUS-120*70 │
│ │ name: 가이드레일 KSS01 SUS 120*70
│ │ options: { │ │
│ │ item_name: "마감재" ← 부품 품명 │ │
│ │ item_sep: "스크린" ← 대분류 │ │
│ │ item_bending: "가이드레일" ← 중분류 │ │
│ │ material: "SUS 1.2T" ← 재질 │ │
│ │ model_name: "KSS01"소속 모델 │ │
│ │ model_UA: "인정" ← 인정여부 │ │
│ │ item_spec: "120*70" ← 규격 │ │
│ │ rail_width: 70 ← 레일폭 │ │
│ │ bendingData: [ ← 전개도 데이터 │ │
│ │ {no:1, input:10, rate:"", sum:10, ...}, │ │
│ │ {no:2, input:11, rate:"", sum:21, ...},
│ │ ... │ │
│ │ ] │ │
│ │ + 케이스전용: exit_direction, box_width, ... │ │
│ │ } │ │
│ │ code: RM260319 ← LOT 코드 (제품+종류+날짜)│
│ │ legacy_code: BD-LEGACY-042 ← 이전 코드 보존 │ │
│ │ item_name: "마감재" ← 정규 컬럼 (인덱스) │ │
│ │ item_sep: "스크린" ← 정규 컬럼 (인덱스) │ │
│ │ item_bending: "가이드레일" ← 정규 컬럼 (인덱스) │ │
│ │ material: "SUS 1.2T" ← 정규 컬럼 (인덱스) │ │
│ │ model_name: "KSS01" ← 정규 컬럼 │ │
│ │ model_UA: "인정" 정규 컬럼 │ │
│ │ item_spec: "120*70" ← 정규 컬럼 │ │
│ │ rail_width: 70 ← 정규 컬럼 │ │
│ │ + 케이스전용: exit_direction, box_width... 정규 │ │
│ │ bending_data: JSON 배열 ← 전개도 데이터 (내장) │ │
│ │ [{no:1, input:10, rate:"", sum:10, color:true}, │ │
│ │ {no:2, input:11, rate:"", sum:21, color:false}, │ │
│ │ {no:3, input:110, rate:"-1", sum:130}, ...] │ │
│ └──────────────────────────────────────────────────┘ │
│ ↑ 265건 (레거시) + α
│ ↑ 266건 (전건 bending_data JSON 포함)
│ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │
@@ -188,7 +534,7 @@ Step 1 (DB분석) → Step 2 (API) → Step 3 (MNG 화면) → Step 4 (React
│ │ │ │
│ │ 재질별 폭합: SUS 1.2T → 406 | EGI 1.55T → 398 │ │
│ └────────────────────────────────────────────────────────┘ │
│ ↑ 가이드레일 20건 + 케이스 + 하단마감재
│ ↑ 가이드레일 21건 + 케이스 30건 + 하단마감재 11건 = 62건
│ │
└─────────────────────────────────────────────────────────────────┘
```
@@ -391,20 +737,28 @@ Step 1 Step 2 Step 3 Step 4
## 9. 레거시 → SAM 대응표
```
레거시 (5130) SAM
━━━━━━━━━━━━━ ━━━━━
chandj.bending (265건) → items (item_category='BENDING') + options
chandj.guiderail (20건) → guiderail-models API (신규 저장 구조)
레거시 (5130) SAM (테이블 분리 완료)
━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━
chandj.bending (265건) → bending_items (266건, 전용 테이블, 정규 컬럼)
chandj.bending.전개도 배열 → bending_items.bending_data (JSON 컬럼, 내장)
chandj.guiderail (20건) → bending_models (62건, guiderail+shutterbox+bottombar)
guiderail/list.php → MNG /bending/products (절곡품 목록)
bending CRUD → MNG /bending/base (기초관리)
put_guiderail_image.php → 기존 ItemsFileController (R2)
put_guiderail_image.php → 기존 FileController (R2) — ⬜ 이미지 업로드 미완료
fetch_guiderail_detail.php → React GuiderailPreview
drawingTool.js (Canvas) → 2차 구현 (1차는 이미지 업로드만)
inputList[] (별도 배열) → bendingData[] (객체 배열)
bendingrateList[] → bendingData[].rate
sumList[] → bendingData[].sum
colorList[] → bendingData[].color
AList[] → bendingData[].aAngle
inputList[] (별도 배열 5개) → bending_data JSON [{no, input, rate, sum, color, aAngle}]
items (BENDING) + options → items 유지 (재고/BOM용, BendingInfoBuilder 무변경)
bending_item_mappings DROP 완료 (bending_items.code에 흡수)
```
---
## 10. 레거시 guiderail 모듈 상세 분석
> 별도 문서로 분리: [`legacy-guiderail-analysis.md`](./legacy-guiderail-analysis.md)
>
> 포함 내용: 파일 구성(21개), DB 스키마(guiderail/bending), CRUD 흐름,
> 전개도 생성, 구성요소(벽면형/측면형), 검색/필터, guidebook

View File

@@ -388,3 +388,79 @@ php artisan bending-model:import-assembly-images # ✅ 결합형태 이미지 6
- [x] artisan command 7개 (위 목록 참조)
- [x] CRUD 검증 완료
- [x] 이미지 마이그레이션 완료 (총 473건 R2 업로드)
{
"lot_no_code_table": [
{
"제품": "가이드레일(벽면형)",
"제품Code": "R",
"종류": [
{ "종류명": "본체", "Code": "M" },
{ "종류명": "본체(철재)", "Code": "T" },
{ "종류명": "C형", "Code": "C" },
{ "종류명": "D형", "Code": "D" },
{ "종류명": "SUS 마감재", "Code": "S" }
]
},
{
"제품": "가이드레일(측면형)",
"제품Code": "S",
"종류": [
{ "종류명": "본체디딤", "Code": "M" },
{ "종류명": "본체(철재)", "Code": "T" },
{ "종류명": "C형", "Code": "C" },
{ "종류명": "D형", "Code": "D" },
{ "종류명": "SUS 마감재①", "Code": "S" },
{ "종류명": "SUS 마감재②", "Code": "U" }
]
},
{
"제품": "케이스",
"제품Code": "C",
"종류": [
{ "종류명": "전면부", "Code": "F" },
{ "종류명": "점검구", "Code": "P" },
{ "종류명": "린텔부", "Code": "L" },
{ "종류명": "후면코너부", "Code": "B" }
]
},
{
"제품": "하단마감재(스크린)",
"제품Code": "B",
"종류": [
{ "종류명": "SUS", "Code": "S" },
{ "종류명": "EGI", "Code": "E" }
]
},
{
"제품": "하단마감재(철재)",
"제품Code": "T",
"종류": [
{ "종류명": "SUS", "Code": "S" },
{ "종류명": "EGI", "Code": "E" }
]
},
{
"제품": "L-Bar",
"제품Code": "L",
"종류": [
{ "종류명": "스크린용", "Code": "A" }
]
},
{
"제품": "연기차단재",
"제품Code": "G",
"종류": [
{ "종류명": "화이바원단(W50)", "Code": "I" },
{ "종류명": "화이바원단(W80)", "Code": "I" }
]
}
],
"비고": {
"년월일": "Code 참조"
}
}

View File

@@ -0,0 +1,370 @@
# Step 5: Canvas 그리기 기능 (5130 → MNG 적용)
> **프로젝트**: MNG (`sam/mng`)
> **선행 조건**: Step 3 (MNG 화면) 완료
> **참조**: 레거시 `5130/js/imageEditor.js`, `5130/js/drawingModule.js`
> **상태**: ⬜ 분석 완료, 구현 미착수
---
## 1. 레거시 Canvas 분석 결과
### 1-1. 5130 Canvas 구현체 3개
| 파일 | 크기 | 라이브러리 | 용도 | 채택 여부 |
|------|------|-----------|------|:---:|
| `5130/js/imageEditor.js` | ~511줄 | **Fabric.js 5.3.0** | 프로덕션 이미지 에디터 (모달 dialog) | ✅ **채택** |
| `5130/js/drawingModule.js` | ~966줄 | Pure Canvas 2D | 독립 모달 + 전체 UI 포함 | ❌ 중복 |
| `5130/js/drawLib.js` | ~272줄 | Pure Canvas 2D | 경량 버전 | ❌ 기능 부족 |
**채택 이유**: `imageEditor.js`가 Fabric.js 기반으로 가장 안정적이며, 오브젝트 선택/편집/삭제 등 고급 기능 지원.
### 1-2. imageEditor.js 핵심 기능
```
┌─────────────────────────────────────────────────────────┐
│ Canvas Editor (1300×800 모달 dialog) │
├─────────────────────────────────────────────────────────┤
│ │
│ [Polyline] [Free] [Line] [Text] [Eraser] [Select] │ ← 도구 모음
│ [Clear] [Apply] │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ │ │
│ │ Canvas (800×600) │ │
│ │ │ │
│ │ · 직각 고정 모드 (0°/90°/180°/270°) │ │
│ │ · 프리뷰 라인 (대시) │ │
│ │ · SVG 커서 (지우개) │ │
│ │ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 색상: [●검정] [●빨강] [●파랑] [●초록] [●주황] [●보라] │
│ 선굵기: [━━━] 지우개크기: [━━━] │
│ │
└─────────────────────────────────────────────────────────┘
```
**그리기 모드**:
| 모드 | 키보드 | 동작 |
|------|--------|------|
| Polyline | (기본) | 클릭으로 점 찍기 → 선 연결, ESC로 종료 |
| Free | — | 드래그로 자유 그리기 (PencilBrush) |
| Line | L | 클릭+드래그 → 직선, 직각 고정 지원 |
| Text | — | 클릭 → IText 생성 → 인라인 편집 |
| Eraser | — | 드래그로 지우기 (SVG 원형 커서, 5~100px) |
| Select | — | 오브젝트 선택/이동/삭제 (Delete키) |
**직각 고정 알고리즘**:
```javascript
// 각도 계산 후 0°/90°/180°/270° 중 가장 가까운 방향으로 스냅
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
// → horizontal or vertical 결정
```
### 1-3. 5130에서의 사용처
| 화면 | Canvas 크기 | 배경 이미지 | 용도 |
|------|------------|-----------|------|
| `bending/write_form.php` | 370×300 | ❌ 없음 | 전개도 그리기 |
| `guiderail/list.php` | 800×600 | ✅ 제품 사진 위 | 치수 표기 |
| `shutterbox/list.php` | 800×600 | ✅ 제품 사진 위 | 치수 표기 |
| `bottombar/list.php` | 800×600 | ✅ 제품 사진 위 | 치수 표기 |
---
## 2. MNG 현재 Canvas 현황
### 2-1. 기존 Canvas 사용처 (참고용)
| 화면 | 라이브러리 | 용도 | 재활용 |
|------|-----------|------|:---:|
| `esign/sign/sign.blade.php` | signature_pad 4.1.7 | 전자서명 | ❌ 다른 용도 |
| `document-templates/block-editor.blade.php` | Alpine.js DOM | 블록 드래그 | ❌ 다른 용도 |
| `rd/fire-shutter-drawing/index.blade.php` | Pure Canvas | 방화셔터 도면 | ❌ 특화 로직 |
### 2-2. MNG 기술 스택
| 항목 | 현재 | Canvas 추가 시 |
|------|------|---------------|
| JS 프레임워크 | Alpine.js 3.x | Alpine.js 유지 |
| HTMX | 1.9.10 | HTMX 유지 |
| CSS | Tailwind + DaisyUI | 유지 |
| Canvas 라이브러리 | ❌ 없음 | **Fabric.js 5.3.0 CDN 추가** |
| 아이콘 | RemixIcon | RemixIcon (또는 Bootstrap Icons 병행) |
---
## 3. 적용 대상 화면
### 3-1. 기초관리 폼 (`/bending/base/{id}/edit`)
```
현재 (1차 구현): 추가 (2차):
┌──────────────────┐ ┌──────────────────┐
│ [형상 이미지] │ │ [형상 이미지] │
│ │ │ │
│ ┌────────────┐ │ │ ┌────────────┐ │
│ │ 미리보기 │ │ │ │ 미리보기 │ │
│ └────────────┘ │ → │ └────────────┘ │
│ │ │ │
│ [파일 선택] │ │ [파일 선택] │
│ [Ctrl+V 붙여넣기]│ │ [Ctrl+V 붙여넣기]│
│ │ │ [✏️ 그리기] │ ← Canvas 모달
│ │ │ │
└──────────────────┘ └──────────────────┘
```
### 3-2. 절곡품 폼 (`/bending/products/{id}/edit`)
```
현재: 추가:
┌──────────────────┐ ┌──────────────────┐
│ [결합형태 이미지] │ │ [결합형태 이미지] │
│ [파일 선택] │ → │ [파일 선택] │
│ │ │ [✏️ 그리기] │ ← Canvas 모달
└──────────────────┘ └──────────────────┘
```
---
## 4. 구현 설계
### 4-1. 파일 구조
```
mng/
├── public/js/
│ └── canvas-editor.js ← imageEditor.js 이식 (MNG 맞춤)
├── resources/views/
│ └── components/
│ └── canvas-editor.blade.php ← 모달 Blade 컴포넌트
```
### 4-2. Fabric.js 로딩
```html
<!-- layouts/app.blade.php 또는 필요한 페이지에서만 -->
@stack('scripts')
<!-- canvas-editor 사용 페이지에서 -->
@push('scripts')
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.0/fabric.min.js"></script>
<script src="{{ asset('js/canvas-editor.js') }}"></script>
@endpush
```
### 4-3. Blade 컴포넌트 설계
```html
<!-- 사용법 -->
<x-canvas-editor
target-input="image_data" {{-- hidden input name --}}
:existing-image="$imageUrl" {{-- 기존 이미지 URL --}}
:width="800"
:height="600"
/>
<!-- 렌더링 -->
<dialog id="canvas-editor-dialog" class="modal">
<div class="modal-box max-w-6xl p-0">
<!-- 도구 모음 -->
<div class="flex gap-1 p-2 bg-base-200 border-b">
<button class="btn btn-sm" data-mode="polyline">Polyline</button>
<button class="btn btn-sm" data-mode="free">Free</button>
<button class="btn btn-sm" data-mode="line">Line</button>
<button class="btn btn-sm" data-mode="text">Text</button>
<button class="btn btn-sm btn-warning" data-mode="eraser">Eraser</button>
<button class="btn btn-sm" data-mode="select">Select</button>
<div class="divider divider-horizontal mx-0"></div>
<input type="checkbox" class="toggle toggle-sm" id="right-angle" checked>
<label for="right-angle" class="text-sm">직각고정</label>
<div class="flex-1"></div>
<button class="btn btn-sm btn-error" id="canvas-clear">초기화</button>
<button class="btn btn-sm btn-success" id="canvas-apply">적용</button>
</div>
<!-- Canvas -->
<canvas id="fabric-canvas" width="800" height="600"></canvas>
<!-- 색상/옵션 -->
<div class="flex gap-2 p-2 bg-base-200 border-t items-center">
<!-- 색상 팔레트 -->
<div class="flex gap-1">
<button class="w-6 h-6 rounded-full bg-black" data-color="#000000"></button>
<button class="w-6 h-6 rounded-full bg-red-600" data-color="#ff0000"></button>
<button class="w-6 h-6 rounded-full bg-blue-600" data-color="#0000ff"></button>
<button class="w-6 h-6 rounded-full bg-green-600" data-color="#00aa00"></button>
<button class="w-6 h-6 rounded-full bg-orange-500" data-color="#ff8800"></button>
<button class="w-6 h-6 rounded-full bg-purple-600" data-color="#800080"></button>
</div>
<div class="divider divider-horizontal mx-0"></div>
<!-- 선 굵기/지우개 크기 -->
<label class="text-sm">선:</label>
<input type="range" min="1" max="10" value="2" class="range range-xs w-20" id="line-width">
<label class="text-sm">지우개:</label>
<input type="range" min="5" max="100" value="20" class="range range-xs w-20" id="eraser-size">
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
```
### 4-4. canvas-editor.js 핵심 구조
```javascript
class CanvasEditor {
constructor(options) {
this.canvas = null; // Fabric.js Canvas
this.mode = 'polyline'; // 현재 모드
this.drawColor = '#000000';
this.lineWidth = 2;
this.eraserSize = 20;
this.rightAngle = true; // 직각 고정
this.polyPoints = []; // polyline 점 목록
this.previewLine = null; // 프리뷰 라인
this.targetInput = options.targetInput; // 결과 저장할 input
this.existingImage = options.existingImage;
}
// 주요 메서드
init() // Fabric.js 캔버스 초기화
setMode(mode) // 모드 변경
loadBackgroundImage(url) // 기존 이미지를 배경으로 로드
handlePolyline(e) // polyline 클릭 처리
handleLine(e) // 직선 드래그
handleText(e) // 텍스트 배치
setupEraser() // PencilBrush 지우개 모드
snapToRightAngle(p1,p2) // 직각 고정 계산
clear() // 캔버스 초기화
apply() // Base64 → hidden input, 모달 닫기
toDataURL() // PNG/JPEG 내보내기
}
```
### 4-5. 데이터 흐름
```
[그리기 버튼 클릭]
→ Canvas 모달 열기
→ (기존 이미지 있으면) 배경 이미지로 로드
→ 사용자 그리기 작업
→ [적용] 클릭
→ Canvas → Base64 (DataURL)
→ hidden input에 저장
→ 폼 submit 시 API 전송
→ API: Base64 디코딩 → R2 저장 → file_id 반환
```
---
## 5. 이식 시 변경 사항
### 5-1. 5130 → MNG 차이점
| 항목 | 5130 (레거시) | MNG (SAM) |
|------|-------------|-----------|
| 모달 | `<dialog>` 직접 생성 | DaisyUI `modal` 컴포넌트 |
| 아이콘 | Bootstrap Icons CDN | RemixIcon (이미 로드됨) |
| CSS | 인라인 + Bootstrap | Tailwind + DaisyUI |
| JS 구조 | 전역 함수 | Class 기반 모듈 |
| 이벤트 | jQuery | Vanilla JS + Alpine.js |
| 이미지 저장 | FormData → PHP 파일 저장 | Base64 → API → R2 |
| 배경 이미지 | 별도 로드 로직 | `/files/{id}/view` 프록시 |
### 5-2. 제거할 것 (5130 전용)
```
❌ jQuery 의존성 (MNG는 Alpine.js)
❌ Bootstrap Icons CDN (RemixIcon으로 교체)
❌ 전역 변수/함수 (Class로 캡슐화)
❌ 인라인 CSS 스타일 (Tailwind 클래스로 교체)
```
### 5-3. 추가할 것 (MNG 맞춤)
```
✅ DaisyUI 모달 통합
✅ Alpine.js x-data 바인딩 (모달 상태 관리)
✅ RemixIcon 아이콘 매핑
✅ HTMX 호환 (폼 submit 시 hx-post 지원)
✅ Base64 → API 업로드 로직
✅ 반응형 캔버스 크기 (모바일 대응 불필요 — MNG는 PC 전용)
```
---
## 6. 아이콘 매핑 (Bootstrap Icons → RemixIcon)
| 기능 | Bootstrap Icons | RemixIcon |
|------|----------------|-----------|
| Polyline | `bi-vector-pen` | `ri-pen-nib-line` |
| Free Draw | `bi-brush` | `ri-brush-line` |
| Line | `bi-slash-lg` | `ri-subtract-line` |
| Text | `bi-type` | `ri-text` |
| Eraser | `bi-eraser-fill` | `ri-eraser-line` |
| Select | `bi-cursor-text` | `ri-cursor-line` |
| Clear | (텍스트) | `ri-delete-bin-line` |
| Apply | (텍스트) | `ri-check-line` |
---
## 7. 작업 순서
```
7-1. Fabric.js CDN 추가 (@push('scripts'))
7-2. canvas-editor.js 작성 (imageEditor.js 기반 이식)
7-3. canvas-editor.blade.php 컴포넌트 생성
7-4. 기초관리 폼에 [그리기] 버튼 + 컴포넌트 통합
7-5. 절곡품 폼에 동일 적용
7-6. API 연동 (Base64 → 이미지 업로드)
7-7. 테스트 (그리기 → 저장 → 재로드 → 편집)
```
### 예상 파일 변경
| 파일 | 변경 내용 |
|------|----------|
| `public/js/canvas-editor.js` | 🆕 신규 (imageEditor.js 이식) |
| `resources/views/components/canvas-editor.blade.php` | 🆕 신규 (모달 컴포넌트) |
| `resources/views/bending/base/form.blade.php` | 수정 — [그리기] 버튼 추가 |
| `resources/views/bending/products/form.blade.php` | 수정 — [그리기] 버튼 추가 |
| `resources/views/layouts/app.blade.php` | 확인 — `@stack('scripts')` 존재 여부 |
---
## 8. 주의사항
### 아키텍처
- ✅ MNG는 **샘플 확인용** — 동일 로직을 React에도 적용 예정
-`canvas-editor.js`**프레임워크 무관** Class로 작성 → React 포팅 용이
- ✅ Fabric.js는 CDN으로 로드 (npm 설치 불필요 — MNG는 Vite 빌드 최소화)
- ❌ drawingModule.js (966줄) 이식 불필요 — imageEditor.js로 충분
### 기존 코드 보호
- ⚠️ 기존 이미지 업로드 로직 유지 (그리기는 **추가** 옵션)
- ⚠️ 기존 Ctrl+V 붙여넣기 유지
- ⚠️ `canvas-editor.js`는 새 파일 — 기존 JS 무변경
### 성능
- ⚠️ Fabric.js 5.3.0 = ~800KB (CDN gzip ~200KB) — 필요 페이지에서만 로드
- ⚠️ Base64 이미지 크기 → JPEG 변환 (PNG 대비 1/3~1/5 크기)
---
## 관련 문서
| 문서 | 경로 |
|------|------|
| Step 3 MNG 화면 (이미지 전략) | `step3-MNG화면.md` §7 |
| 레거시 분석 | `legacy-guiderail-analysis.md` |
| 5130 imageEditor.js | `5130/js/imageEditor.js` |
| 5130 drawingModule.js | `5130/js/drawingModule.js` |
---
**최종 업데이트**: 2026-03-18

View File

@@ -294,9 +294,294 @@ BD-XX-XXX 품목이 `items` 테이블에 등록만 되어 있고, **절곡품
4. **전개도 데이터 구조** — 치수/연신율/합계 JSON 저장 방안 설계
5. **이미지 업로드 기능** — 절곡품별 전개도 이미지 관리
---
## 6. API 구현 완료 현황 (2026-03-16)
### 6.1 절곡품 마스터 API (`/api/v1/bending-items`)
| Method | Path | 설명 | 상태 |
|--------|------|------|------|
| GET | `/api/v1/bending-items` | 목록 (필터+페이지네이션) | ✅ 완료 |
| GET | `/api/v1/bending-items/filters` | 필터 옵션 (드롭다운용) | ✅ 완료 |
| GET | `/api/v1/bending-items/{id}` | 상세 | ✅ 완료 |
| POST | `/api/v1/bending-items` | 등록 | ✅ 완료 |
| PUT | `/api/v1/bending-items/{id}` | 수정 | ✅ 완료 |
| DELETE | `/api/v1/bending-items/{id}` | 삭제 | ✅ 완료 |
**API 파일 목록**:
| 파일 | 설명 |
|------|------|
| `app/Http/Controllers/Api/V1/BendingItemController.php` | 컨트롤러 |
| `app/Services/BendingItemService.php` | 서비스 (OPTION_KEYS 정의) |
| `app/Http/Resources/Api/V1/BendingItemResource.php` | 응답 리소스 |
| `app/Http/Requests/Api/V1/BendingItemIndexRequest.php` | 목록 검증 |
| `app/Http/Requests/Api/V1/BendingItemStoreRequest.php` | 등록 검증 |
| `app/Http/Requests/Api/V1/BendingItemUpdateRequest.php` | 수정 검증 |
| `routes/api/v1/production.php` | 라우트 정의 (127~135행) |
### 6.2 이미지 업로드 API (`/api/v1/items/{id}/files`)
| Method | Path | 설명 | 상태 |
|--------|------|------|------|
| POST | `/api/v1/items/{id}/files` | 업로드 (`field_key=bending_diagram`) | ✅ 완료 |
| GET | `/api/v1/items/{id}/files` | 파일 목록 (`?field_key=bending_diagram`) | ✅ 완료 |
| DELETE | `/api/v1/items/{id}/files/{fileId}` | 파일 삭제 | ✅ 완료 |
| GET | `/api/v1/files/{id}/view` | 인라인 보기 (이미지 표시) | ✅ 완료 |
| GET | `/api/v1/files/{id}/download` | 다운로드 | ✅ 완료 |
**R2 저장 경로**: `{tenant_id}/items/{year}/{month}/{hex}.{ext}`
**예시**: `287/items/2026/03/1b4eba14ff5a832b.jpg`
### 6.3 API 응답 구조
#### 절곡품 상세 (`GET /api/v1/bending-items/{id}`)
```json
{
"success": true,
"message": "조회 성공",
"data": {
"id": 15862,
"code": "BD-BE-30",
"name": "하단마감재(스크린) EGI 3000mm",
"item_type": "PT",
"item_category": "BENDING",
"unit": "EA",
"is_active": true,
"item_name": "하단마감재",
"item_sep": "스크린",
"item_bending": "하단마감재",
"item_spec": "60*40",
"material": "EGI 1.55T",
"model_name": null,
"model_UA": "인정",
"search_keyword": null,
"rail_width": null,
"registration_date": "2025-07-21",
"author": "개발자",
"memo": "메모",
"exit_direction": null,
"front_bottom_width": null,
"box_width": null,
"box_height": null,
"bendingData": [
{ "no": 1, "input": 15, "rate": null, "sum": 15, "color": false, "aAngle": false },
{ "no": 2, "input": 14, "rate": "-1", "sum": 28, "color": false, "aAngle": false },
{ "no": 3, "input": 40, "rate": "-1", "sum": 67, "color": false, "aAngle": false }
],
"prefix": "BE",
"length_code": "30",
"length_mm": 3000,
"width_sum": 193,
"bend_count": 6,
"created_at": "2026-02-21 19:47:01",
"updated_at": "2026-03-16 21:11:12"
}
}
```
#### 필터 옵션 (`GET /api/v1/bending-items/filters`)
```json
{
"success": true,
"data": {
"item_sep": ["스크린", "철재"],
"item_bending": ["가이드레일", "케이스", "하단마감재", "마구리", "L-BAR"],
"material": ["EGI 1.15T", "EGI 1.55T", "SUS 1.2T", "SUS 1.5T"],
"model_UA": ["비인정", "인정"],
"model_name": ["KSS01", "KSS02", "KSE01"]
}
}
```
### 6.4 MNG 관리 화면 (완료)
| 화면 | URL | 설명 |
|------|-----|------|
| 목록 | `/bending/base` | 필터 + 페이지네이션 |
| 등록 | `/bending/base/create` | 폼 + 전개도 테이블 + 이미지 업로드 |
| 상세 | `/bending/base/{id}` | 읽기 전용 |
| 수정 | `/bending/base/{id}/edit` | 수정 모드 + 이미지 교체 |
### 6.5 레거시 분류 조건 분석
`item_bending` 컬럼으로 타입 구분 (같은 bending 테이블 사용):
| `item_bending` 값 | 전용 필드 | 레거시 디렉토리 |
|-------------------|----------|----------------|
| `가이드레일` | `rail_width` | `/bending/` |
| `케이스` | `exit_direction`, `box_width`, `box_height`, `front_bottom_width` | `/shutterbox/` |
| `하단마감재` | 없음 (공통 필드만) | `/bottombar/` |
| `마구리` | 없음 | - |
| `L-BAR` | 없음 | - |
| `보강평철` | 없음 | - |
| `연기차단재` | 없음 | - |
---
## 7. React 연동 시 참고사항
### 7.1 서버 액션 추가 필요
React에 `/api/v1/bending-items` 전용 서버 액션이 없음. 추가 필요:
```
예상 파일: src/components/bending/actions.ts
필요 함수:
- fetchBendingItems(params) → GET /api/v1/bending-items
- fetchBendingFilters() → GET /api/v1/bending-items/filters
- fetchBendingItem(id) → GET /api/v1/bending-items/{id}
- createBendingItem(data) → POST /api/v1/bending-items
- updateBendingItem(id, data) → PUT /api/v1/bending-items/{id}
- deleteBendingItem(id) → DELETE /api/v1/bending-items/{id}
- uploadBendingImage(itemId, file) → POST /api/v1/items/{id}/files
```
### 7.2 bendingData 필드 매핑 (API → React)
API 응답의 `bendingData`와 기존 React `BendingDetail` 타입 불일치:
| API 필드 | API 타입 | React 기존 타입 (`BendingDetail`) | 조치 |
|----------|---------|----------------------------------|------|
| `no` | integer | `no: number` | ✅ 일치 |
| `input` | numeric | `input: number` | ✅ 일치 |
| `rate` | string \| null | `elongation: number` | ⚠️ 이름+타입 다름 |
| `sum` | numeric | `sum: number` | ✅ 일치 |
| `color` | boolean | `shaded: boolean` | ⚠️ 이름 다름 |
| `aAngle` | boolean | `aAngle?: number` | ⚠️ 타입 다름 |
| - | - | `id: string` | React에만 존재 (클라이언트 전용) |
| - | - | `calculated: number` | React에만 존재 (클라이언트 계산값) |
**권장**: React 타입을 API에 맞추거나, 변환 레이어 추가
```typescript
// API → React 변환 예시
function transformBendingData(apiData: ApiBendingData[]): BendingDetail[] {
return apiData.map((d, i) => ({
id: `detail-${i}`,
no: d.no,
input: d.input,
elongation: d.rate ? parseFloat(d.rate) : -1, // rate → elongation
calculated: d.input + (d.rate ? parseFloat(d.rate) : 0),
sum: d.sum,
shaded: d.color, // color → shaded
aAngle: d.aAngle ? 1 : 0, // boolean → number
}));
}
```
### 7.3 인증 방식
API는 현재 **Bearer 토큰 없이 X-TENANT-ID 헤더**로 동작 (화이트리스트):
- `api/v1/bending-items`, `api/v1/bending-items/*`
- `api/v1/items/*/files`
- `api/v1/files/*/view`, `api/v1/files/*/download`
React는 **HttpOnly Cookie + Next.js 프록시**로 인증하므로, Bearer 토큰이 자동 전달됨.
React 연동 시 화이트리스트 의존 없이 정상 인증 경로로 동작할 것.
### 7.4 이미지 표시 경로
```
React에서 이미지 표시:
프록시 경로: /api/proxy/files/{fileId}/view
→ Next.js API Route가 Bearer 토큰 붙여서
→ API: GET /api/v1/files/{fileId}/view
→ R2에서 stream
```
### 7.5 Update 시 code unique 검증
`BendingItemUpdateRequest`에 자기 자신 제외 unique 체크 누락:
```php
// 현재 (미흡)
'code' => 'sometimes|string|max:100',
// 수정 필요
'code' => 'sometimes|string|max:100|unique:items,code,' . $this->route('id'),
```
### 7.6 React API 호환성 검증 결과 (2026-03-17)
#### 호환 항목 (API 수정 불필요)
| 항목 | 상태 | 비고 |
|------|------|------|
| 응답 래핑 `{success, message, data}` | ✅ 호환 | ApiResponse 헬퍼 공용 |
| 페이지네이션 구조 | ✅ 호환 | `toPaginationMeta()` 재사용 가능 |
| 에러 응답 (422/404/500) | ✅ 호환 | 동일 에러 핸들링 구조 |
| Null 처리 | ✅ 호환 | 선택적 필드 패턴 일치 |
| 날짜 형식 | ✅ 호환 | `Y-m-d` 동일 |
| snake_case → camelCase | ✅ 호환 | 기존 `transformItemFromApi()` 재사용 |
#### React 측 작업 필요 항목
**1. bendingData 필드 매핑** (Server Action 레벨)
| API 응답 (`bendingData`) | React 타입 (`BendingDetail`) | 조치 |
|--------------------------|------------------------------|------|
| `rate` (string\|null) | `elongation` (number) | 이름+타입 변환 |
| `color` (boolean) | `shaded` (boolean) | 이름 변환 |
| `aAngle` (boolean) | `aAngle` (number) | 타입 변환 |
| `input`, `sum`, `no` | 동일 | ✅ 일치 |
변환 예시:
```typescript
function transformBendingData(apiData: ApiBendingData[]): BendingDetail[] {
return apiData.map((d, i) => ({
id: `detail-${i}`,
no: d.no,
input: d.input,
elongation: d.rate ? parseFloat(d.rate) : -1,
calculated: d.input + (d.rate ? parseFloat(d.rate) : 0),
sum: d.sum,
shaded: d.color,
aAngle: d.aAngle ? 1 : 0,
}));
}
```
**2. GuiderailModel 타입 + API 클라이언트 신규 작성**
- React에 `GuiderailModel` 관련 타입 없음
- `components[]`, `material_summary` 필드 구조 정의 필요
- Server Action: `fetchGuiderailModels()`, `fetchGuiderailModel(id)`
**3. 파일 URL 프록시 처리**
- API: `/api/v1/files/{id}/view`
- React: `/api/proxy/files/{id}/view` (Next.js 프록시 경로)
#### 인증 방식 차이 (자동 호환)
| 호출자 | X-API-KEY | Bearer | tenant_id 소스 | 비고 |
|--------|-----------|--------|---------------|------|
| **MNG** | ✅ | ❌ | X-TENANT-ID 헤더 (`ensureContext`) | 현재 |
| **React** | ✅ (프록시) | ✅ (쿠키) | Bearer → User → userTenants | 향후 |
React는 Next.js 프록시가 HttpOnly 쿠키에서 Bearer 토큰을 읽어 자동 첨부하므로,
API의 `allowWithoutAuth` 화이트리스트에 의존하지 않고 정상 인증 경로로 동작함.
`ensureContext()`는 Bearer 없을 때만 동작하는 fallback이라 충돌 없음.
### 7.7 MNG 부품 추가 시 리다이렉트 수정 (2026-03-17)
**문제**: `/bending/cases/{id}/edit`에서 부품 추가 시 `form.submit()` → 컨트롤러 `update()`가 show 페이지로 redirect, edit 모드 종료됨
**수정 내용**:
- `form.blade.php`: `submitAndStayEdit()` 함수 추가 — hidden input `_redirect=edit` 세팅 후 submit
- `BendingProductController::update()`: `_redirect=edit`이면 edit 페이지로 리다이렉트
- `movePart()` 순서 변경도 동일 패턴 적용 (`location.reload()``submitAndStayEdit()`)
---
### 관련 문서
- 통합 마스터 플랜: `docs/dev/dev_plans/integrated-master-plan.md`
- Phase 2 (절곡 분석/설계): `docs/dev/dev_plans/integrated-phase-2.md`
- Phase 3 (절곡 검사, 완료): `docs/dev/dev_plans/integrated-phase-3.md`
- 품목 정책: `docs/rules/item-policy.md`
- 품목 정책: `docs/rules/item-policy.md`
---
**최종 업데이트**: 2026-03-17

File diff suppressed because it is too large Load Diff

View File

@@ -1,150 +1,150 @@
# Claude Code /btw (Side Question) 기능 가이드
> **작성일**: 2026-03-14
> **상태**: 확정
> **도입 버전**: Claude Code v2.1.72 (2026-03-10)
---
## 1. 개요
### 1.1 목적
`/btw` (By The Way)는 Claude Code에서 **작업 중단 없이 빠른 질문**을 할 수 있는 사이드 질문 기능이다. 대화 이력에 추가되지 않으며, 현재 세션의 컨텍스트를 기반으로 즉답을 제공한다.
### 1.2 핵심 원칙
- 대화 이력을 오염시키지 않는 임시 질문
- Claude가 작업 중일 때도 사용 가능
- 도구(파일 읽기, 명령 실행 등)에 접근하지 않고 **이미 알고 있는 정보만** 활용
- 프롬프트 캐시를 재사용하여 토큰 비용 최소화
---
## 2. 사용법
### 2.1 기본 문법
```
/btw 질문 내용
```
### 2.2 사용 예시
```
/btw 아까 수정한 설정 파일 이름이 뭐였지?
/btw 우리 DB 커넥션 이름이 뭐야?
/btw 방금 만든 API 엔드포인트 경로가 뭐지?
/btw tenant_id 컬럼 타입이 뭐였지?
/btw 아까 논의한 마이그레이션 순서 알려줘
```
### 2.3 답변 닫기
답변은 오버레이 형태로 표시되며, 아래 키로 닫을 수 있다:
| 키 | 동작 |
|----|------|
| `Space` | 닫기 |
| `Enter` | 닫기 |
| `Escape` | 닫기 |
---
## 3. 특징
### 3.1 작업 중에도 사용 가능
Claude가 코드를 작성하거나 파일을 읽는 중에도 `/btw`를 실행할 수 있다. 메인 작업을 중단하지 않는다.
### 3.2 전체 컨텍스트 접근
사이드 질문은 현재 대화의 전체 컨텍스트를 볼 수 있다:
- Claude가 이미 읽은 코드
- 이전 대화에서 논의한 아키텍처 결정
- 세션 중 수행한 모든 작업 내역
### 3.3 도구 접근 불가
```
❌ 파일 읽기/쓰기
❌ 명령어 실행 (bash, git 등)
❌ 웹 검색
❌ 새로운 정보 탐색
```
오직 **현재 컨텍스트에 있는 정보만** 사용하여 답변한다.
### 3.4 단발성 응답
후속 대화(follow-up)가 불가능하다. 추가 질문이 필요하면 일반 프롬프트를 사용한다.
### 3.5 비용 효율성
- 부모 대화의 프롬프트 캐시를 재사용
- 대화 이력에 추가되지 않아 이후 토큰 소비 없음
- 동일 정보를 일반 프롬프트로 물어보는 것 대비 비용 절감
---
## 4. /btw vs 서브에이전트 비교
| 항목 | `/btw` | 서브에이전트 (Agent) |
|------|--------|---------------------|
| **컨텍스트** | 전체 대화 내용 접근 가능 | 빈 컨텍스트에서 시작 |
| **도구 접근** | 불가 | 전체 도구 사용 가능 |
| **용도** | 이미 아는 정보 조회 | 새로운 정보 탐색 |
| **작업 중단** | 없음 | 병렬 실행 가능 |
| **대화 이력** | 추가되지 않음 | 결과가 이력에 포함 |
| **비용** | 최소 (캐시 재사용) | 별도 토큰 소비 |
### 4.1 판단 기준
```
"Claude가 이미 알고 있는 정보인가?"
→ Yes → /btw 사용
→ No → 일반 프롬프트 또는 서브에이전트 사용
```
---
## 5. 사용 요건
| 항목 | 요건 |
|------|------|
| 환경 | Claude Code CLI (터미널) 전용 |
| 버전 | v2.1.72 이상 |
| 계정 | Pro, Max, Teams, Enterprise, Console |
---
## 6. 베스트 프랙티스
### 6.1 적합한 사용 사례
```
✅ 파일명, 경로, 변수명 등 참조 정보 확인
✅ 이전 논의에서 결정한 사항 재확인
✅ 현재 작업 컨텍스트에 대한 빠른 질문
✅ 코드 구조나 아키텍처 결정 사항 확인
```
### 6.2 부적합한 사용 사례
```
❌ 새 파일을 읽어야 하는 질문
❌ 명령어 실행이 필요한 작업
❌ 웹 검색이 필요한 조사
❌ 후속 대화가 필요한 복잡한 논의
```
---
## 관련 문서
- [Claude Code → 슬랙 붙여넣기 가이드](claude-code-to-slack.md)
- [개발 명령어 모음](../quickstart/dev-commands.md)
---
**최종 업데이트**: 2026-03-14
# Claude Code /btw (Side Question) 기능 가이드
> **작성일**: 2026-03-14
> **상태**: 확정
> **도입 버전**: Claude Code v2.1.72 (2026-03-10)
---
## 1. 개요
### 1.1 목적
`/btw` (By The Way)는 Claude Code에서 **작업 중단 없이 빠른 질문**을 할 수 있는 사이드 질문 기능이다. 대화 이력에 추가되지 않으며, 현재 세션의 컨텍스트를 기반으로 즉답을 제공한다.
### 1.2 핵심 원칙
- 대화 이력을 오염시키지 않는 임시 질문
- Claude가 작업 중일 때도 사용 가능
- 도구(파일 읽기, 명령 실행 등)에 접근하지 않고 **이미 알고 있는 정보만** 활용
- 프롬프트 캐시를 재사용하여 토큰 비용 최소화
---
## 2. 사용법
### 2.1 기본 문법
```
/btw 질문 내용
```
### 2.2 사용 예시
```
/btw 아까 수정한 설정 파일 이름이 뭐였지?
/btw 우리 DB 커넥션 이름이 뭐야?
/btw 방금 만든 API 엔드포인트 경로가 뭐지?
/btw tenant_id 컬럼 타입이 뭐였지?
/btw 아까 논의한 마이그레이션 순서 알려줘
```
### 2.3 답변 닫기
답변은 오버레이 형태로 표시되며, 아래 키로 닫을 수 있다:
| 키 | 동작 |
|----|------|
| `Space` | 닫기 |
| `Enter` | 닫기 |
| `Escape` | 닫기 |
---
## 3. 특징
### 3.1 작업 중에도 사용 가능
Claude가 코드를 작성하거나 파일을 읽는 중에도 `/btw`를 실행할 수 있다. 메인 작업을 중단하지 않는다.
### 3.2 전체 컨텍스트 접근
사이드 질문은 현재 대화의 전체 컨텍스트를 볼 수 있다:
- Claude가 이미 읽은 코드
- 이전 대화에서 논의한 아키텍처 결정
- 세션 중 수행한 모든 작업 내역
### 3.3 도구 접근 불가
```
❌ 파일 읽기/쓰기
❌ 명령어 실행 (bash, git 등)
❌ 웹 검색
❌ 새로운 정보 탐색
```
오직 **현재 컨텍스트에 있는 정보만** 사용하여 답변한다.
### 3.4 단발성 응답
후속 대화(follow-up)가 불가능하다. 추가 질문이 필요하면 일반 프롬프트를 사용한다.
### 3.5 비용 효율성
- 부모 대화의 프롬프트 캐시를 재사용
- 대화 이력에 추가되지 않아 이후 토큰 소비 없음
- 동일 정보를 일반 프롬프트로 물어보는 것 대비 비용 절감
---
## 4. /btw vs 서브에이전트 비교
| 항목 | `/btw` | 서브에이전트 (Agent) |
|------|--------|---------------------|
| **컨텍스트** | 전체 대화 내용 접근 가능 | 빈 컨텍스트에서 시작 |
| **도구 접근** | 불가 | 전체 도구 사용 가능 |
| **용도** | 이미 아는 정보 조회 | 새로운 정보 탐색 |
| **작업 중단** | 없음 | 병렬 실행 가능 |
| **대화 이력** | 추가되지 않음 | 결과가 이력에 포함 |
| **비용** | 최소 (캐시 재사용) | 별도 토큰 소비 |
### 4.1 판단 기준
```
"Claude가 이미 알고 있는 정보인가?"
→ Yes → /btw 사용
→ No → 일반 프롬프트 또는 서브에이전트 사용
```
---
## 5. 사용 요건
| 항목 | 요건 |
|------|------|
| 환경 | Claude Code CLI (터미널) 전용 |
| 버전 | v2.1.72 이상 |
| 계정 | Pro, Max, Teams, Enterprise, Console |
---
## 6. 베스트 프랙티스
### 6.1 적합한 사용 사례
```
✅ 파일명, 경로, 변수명 등 참조 정보 확인
✅ 이전 논의에서 결정한 사항 재확인
✅ 현재 작업 컨텍스트에 대한 빠른 질문
✅ 코드 구조나 아키텍처 결정 사항 확인
```
### 6.2 부적합한 사용 사례
```
❌ 새 파일을 읽어야 하는 질문
❌ 명령어 실행이 필요한 작업
❌ 웹 검색이 필요한 조사
❌ 후속 대화가 필요한 복잡한 논의
```
---
## 관련 문서
- [Claude Code → 슬랙 붙여넣기 가이드](claude-code-to-slack.md)
- [개발 명령어 모음](../quickstart/dev-commands.md)
---
**최종 업데이트**: 2026-03-14

View File

@@ -1,116 +1,116 @@
# Claude Code CLI 출력을 슬랙에 전달하는 방법
> **작성일**: 2026-03-11
> **대상**: 슬랙으로 협업하는 모든 팀원
---
## 1. 문제
Claude Code CLI에서 복사한 텍스트를 슬랙 채팅창에 붙여넣으면 줄바꿈이 깨지고, 마크다운 문법이 원본 그대로 노출된다.
---
## 2. 원인 분석
텍스트가 깨지는 원인은 3가지가 복합적으로 작용한다.
### 2.1 터미널 줄바꿈 혼재
터미널에는 2종류의 줄바꿈이 존재한다.
| 종류 | 설명 | 복사 시 동작 |
|------|------|-------------|
| Hard wrap | 원본 텍스트의 실제 `\n` | 보통 유지됨 |
| Soft wrap | 터미널 창 너비에 의한 시각적 줄바꿈 | 유실되거나 불필요하게 추가됨 |
Claude Code CLI는 터미널에서 실행되므로, 드래그 복사 시 실제 줄바꿈과 시각적 줄바꿈이 뒤섞여 클립보드에 들어간다. 그 결과 어떤 줄은 합쳐지고, 어떤 줄은 엉뚱한 곳에서 끊어진다.
### 2.2 Markdown vs Slack mrkdwn 문법 차이
Claude Code는 표준 Markdown을 출력하지만, 슬랙은 자체 마크업 문법(mrkdwn)을 사용한다.
| 요소 | Markdown (Claude Code) | Slack mrkdwn | 직접 붙여넣기 시 |
|------|----------------------|-------------|----------------|
| 볼드 | `**텍스트**` | `*텍스트*` | `**텍스트**` 그대로 표시 |
| 이탤릭 | `*텍스트*` | `_텍스트_` | 슬랙에서 볼드로 오인식 |
| 제목 | `## 제목` | 지원 안 함 | `## 제목` 그대로 표시 |
| 취소선 | `~~텍스트~~` | `~텍스트~` | `~~텍스트~~` 그대로 표시 |
| 링크 | `[텍스트](URL)` | `<URL\|텍스트>` | 원본 문법 그대로 표시 |
| 구분선 | `---` | 지원 안 함 | `---` 대시 3개로 표시 |
| 코드블록 | ` ```lang ``` ` | ` ``` ``` ` | 언어 지정자가 텍스트로 노출될 수 있음 |
### 2.3 슬랙 입력창의 공백 처리
슬랙의 메시지 입력창은 붙여넣기 시 다음 처리를 수행한다:
- 연속 빈 줄을 1개로 축소
- 앞뒤 공백 제거
- 일부 특수문자 이스케이프
이 3가지가 합쳐지면 **줄 합침, 문법 노출, 공백 손실**이 동시에 발생한다.
---
## 3. 해결 방법: 클코 → 슬랙 변환기
### 3.1 접속 경로
MNG 관리자 패널에 변환 도구가 있다.
| 환경 | URL |
|------|-----|
| 로컬 | `http://mng.sam.kr/rd/cc-to-slack` |
| 개발 서버 | `https://admin.codebridge-x.com/rd/cc-to-slack` |
| 운영 서버 | `https://mng.codebridge-x.com/rd/cc-to-slack` |
**메뉴 위치**: 연구개발 > 클코 to 슬랙형태
### 3.2 사용법
1. Claude Code CLI에서 메시지를 드래그하여 복사 (`Ctrl+C`)
2. 변환기의 왼쪽 입력란에 붙여넣기 (`Ctrl+V`)
3. 오른쪽 슬랙 미리보기에 변환 결과가 자동 표시됨
4. **복사** 버튼 클릭 (또는 `Ctrl+Enter`)
5. 슬랙 채팅창에 붙여넣기 (`Ctrl+V`)
### 3.3 변환 규칙
| 변환 전 (Markdown) | 변환 후 (Slack) | 설명 |
|-------------------|----------------|------|
| `**볼드**` | `*볼드*` | 슬랙 볼드 문법 |
| `## 제목` | `*제목*` | 볼드 처리로 대체 |
| `~~취소~~` | `~취소~` | 슬랙 취소선 문법 |
| `[텍스트](URL)` | `<URL\|텍스트>` | 슬랙 링크 문법 |
| `---` | `———` | em dash로 구분선 대체 |
| 테이블 구분선 `\|---\|` | 제거 | 불필요한 구분선 삭제 |
| 연속 빈 줄 | 1개로 정리 | 공백 정리 |
| 코드블록 | 유지 | 슬랙도 ` ``` ` 지원 |
---
## 4. 작동 원리
변환기가 정상 작동하는 핵심 이유는 **리치 텍스트(HTML) 복사** 방식을 사용하기 때문이다.
```
일반 텍스트 복사 (plain text)
→ 슬랙이 자체적으로 재해석 → 줄바꿈 깨짐, 문법 노출
리치 텍스트 복사 (HTML)
→ 슬랙이 HTML 서식을 그대로 수용 → 볼드, 코드블록, 줄바꿈 보존
```
브라우저의 Selection API로 DOM 요소를 선택하면 클립보드에 `text/html``text/plain` 두 가지 형식이 동시에 저장된다. 슬랙은 `text/html` 버전을 읽어서 `<b>`, `<code>`, `<br>` 등의 서식을 그대로 적용한다.
---
## 관련 문서
- MNG 소스: `mng/resources/views/rd/cc-to-slack/index.blade.php`
- 컨트롤러: `mng/app/Http/Controllers/RdController.php``ccToSlack()`
- 라우트: `GET /rd/cc-to-slack`
---
**최종 업데이트**: 2026-03-11
# Claude Code CLI 출력을 슬랙에 전달하는 방법
> **작성일**: 2026-03-11
> **대상**: 슬랙으로 협업하는 모든 팀원
---
## 1. 문제
Claude Code CLI에서 복사한 텍스트를 슬랙 채팅창에 붙여넣으면 줄바꿈이 깨지고, 마크다운 문법이 원본 그대로 노출된다.
---
## 2. 원인 분석
텍스트가 깨지는 원인은 3가지가 복합적으로 작용한다.
### 2.1 터미널 줄바꿈 혼재
터미널에는 2종류의 줄바꿈이 존재한다.
| 종류 | 설명 | 복사 시 동작 |
|------|------|-------------|
| Hard wrap | 원본 텍스트의 실제 `\n` | 보통 유지됨 |
| Soft wrap | 터미널 창 너비에 의한 시각적 줄바꿈 | 유실되거나 불필요하게 추가됨 |
Claude Code CLI는 터미널에서 실행되므로, 드래그 복사 시 실제 줄바꿈과 시각적 줄바꿈이 뒤섞여 클립보드에 들어간다. 그 결과 어떤 줄은 합쳐지고, 어떤 줄은 엉뚱한 곳에서 끊어진다.
### 2.2 Markdown vs Slack mrkdwn 문법 차이
Claude Code는 표준 Markdown을 출력하지만, 슬랙은 자체 마크업 문법(mrkdwn)을 사용한다.
| 요소 | Markdown (Claude Code) | Slack mrkdwn | 직접 붙여넣기 시 |
|------|----------------------|-------------|----------------|
| 볼드 | `**텍스트**` | `*텍스트*` | `**텍스트**` 그대로 표시 |
| 이탤릭 | `*텍스트*` | `_텍스트_` | 슬랙에서 볼드로 오인식 |
| 제목 | `## 제목` | 지원 안 함 | `## 제목` 그대로 표시 |
| 취소선 | `~~텍스트~~` | `~텍스트~` | `~~텍스트~~` 그대로 표시 |
| 링크 | `[텍스트](URL)` | `<URL\|텍스트>` | 원본 문법 그대로 표시 |
| 구분선 | `---` | 지원 안 함 | `---` 대시 3개로 표시 |
| 코드블록 | ` ```lang ``` ` | ` ``` ``` ` | 언어 지정자가 텍스트로 노출될 수 있음 |
### 2.3 슬랙 입력창의 공백 처리
슬랙의 메시지 입력창은 붙여넣기 시 다음 처리를 수행한다:
- 연속 빈 줄을 1개로 축소
- 앞뒤 공백 제거
- 일부 특수문자 이스케이프
이 3가지가 합쳐지면 **줄 합침, 문법 노출, 공백 손실**이 동시에 발생한다.
---
## 3. 해결 방법: 클코 → 슬랙 변환기
### 3.1 접속 경로
MNG 관리자 패널에 변환 도구가 있다.
| 환경 | URL |
|------|-----|
| 로컬 | `http://mng.sam.kr/rd/cc-to-slack` |
| 개발 서버 | `https://admin.codebridge-x.com/rd/cc-to-slack` |
| 운영 서버 | `https://mng.codebridge-x.com/rd/cc-to-slack` |
**메뉴 위치**: 연구개발 > 클코 to 슬랙형태
### 3.2 사용법
1. Claude Code CLI에서 메시지를 드래그하여 복사 (`Ctrl+C`)
2. 변환기의 왼쪽 입력란에 붙여넣기 (`Ctrl+V`)
3. 오른쪽 슬랙 미리보기에 변환 결과가 자동 표시됨
4. **복사** 버튼 클릭 (또는 `Ctrl+Enter`)
5. 슬랙 채팅창에 붙여넣기 (`Ctrl+V`)
### 3.3 변환 규칙
| 변환 전 (Markdown) | 변환 후 (Slack) | 설명 |
|-------------------|----------------|------|
| `**볼드**` | `*볼드*` | 슬랙 볼드 문법 |
| `## 제목` | `*제목*` | 볼드 처리로 대체 |
| `~~취소~~` | `~취소~` | 슬랙 취소선 문법 |
| `[텍스트](URL)` | `<URL\|텍스트>` | 슬랙 링크 문법 |
| `---` | `———` | em dash로 구분선 대체 |
| 테이블 구분선 `\|---\|` | 제거 | 불필요한 구분선 삭제 |
| 연속 빈 줄 | 1개로 정리 | 공백 정리 |
| 코드블록 | 유지 | 슬랙도 ` ``` ` 지원 |
---
## 4. 작동 원리
변환기가 정상 작동하는 핵심 이유는 **리치 텍스트(HTML) 복사** 방식을 사용하기 때문이다.
```
일반 텍스트 복사 (plain text)
→ 슬랙이 자체적으로 재해석 → 줄바꿈 깨짐, 문법 노출
리치 텍스트 복사 (HTML)
→ 슬랙이 HTML 서식을 그대로 수용 → 볼드, 코드블록, 줄바꿈 보존
```
브라우저의 Selection API로 DOM 요소를 선택하면 클립보드에 `text/html``text/plain` 두 가지 형식이 동시에 저장된다. 슬랙은 `text/html` 버전을 읽어서 `<b>`, `<code>`, `<br>` 등의 서식을 그대로 적용한다.
---
## 관련 문서
- MNG 소스: `mng/resources/views/rd/cc-to-slack/index.blade.php`
- 컨트롤러: `mng/app/Http/Controllers/RdController.php``ccToSlack()`
- 라우트: `GET /rd/cc-to-slack`
---
**최종 업데이트**: 2026-03-11

File diff suppressed because it is too large Load Diff

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