# tenant_id 준수 분석 및 분리 방안

> **작성일**: 2026-01-29
> **목적**: API 전체 모델에서 tenant_id 스코핑 미적용 현황을 분석하고, BelongsToTenant trait 적용 방안 수립
> **기준 문서**: `docs/specs/database-schema.md`, `docs/architecture/system-overview.md`
> **상태**: 🔄 분석 완료 → 실행 대기

---

## 📍 현재 진행 상태

| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | 전체 모델 분석 완료 |
| **다음 작업** | 사용자 검토 후 Phase 1 실행 |
| **진행률** | 0/4 Phase (0%) |
| **마지막 업데이트** | 2026-01-29 |

---

## 1. 개요

### 1.1 배경

SAM API는 멀티테넌트 아키텍처를 사용하며, `BelongsToTenant` trait를 통해 자동 tenant_id 스코핑을 적용합니다. 그러나 일부 모델에서 trait가 누락되어 있어, 테넌트 간 데이터 격리가 보장되지 않을 수 있습니다.

**분석 결과 요약:**
- 전체 모델: 167개
- BelongsToTenant 적용: 103개 (61.7%)
- 미적용: 63개 (37.7%)
  - 의도적 글로벌: 18개
  - 부모 종속 (FK 격리): 13개
  - **BelongsToTenant 추가 필요: 27개**
  - **검토 후 결정: 5개**

### 1.2 기준 원칙

```
┌─────────────────────────────────────────────────────────────────┐
│  🎯 핵심 원칙                                                    │
├─────────────────────────────────────────────────────────────────┤
│  1. tenant_id 컬럼이 있는 모델은 BelongsToTenant 적용 필수       │
│  2. 부모-자식 관계에서 자식은 부모의 FK로 격리 가능하면 면제      │
│  3. 시스템 전역 데이터(User, Tenant, ApiKey 등)는 글로벌 유지     │
│  4. Boards 영역은 시스템/테넌트 혼용이므로 커스텀 스코프 유지      │
└─────────────────────────────────────────────────────────────────┘
```

### 1.3 변경 승인 정책

| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | BelongsToTenant trait 추가 (기존 동작 유지) | 불필요 |
| ⚠️ 컨펌 필요 | Boards 영역 스코핑 방식 변경, 쿼리 로직 수정 | **필수** |
| 🔴 금지 | 테이블 구조 변경, tenant_id 컬럼 추가/삭제 | 별도 협의 |

### 1.4 준수 규칙
- `docs/specs/database-schema.md` - 테이블 구조
- `docs/architecture/system-overview.md` - 시스템 아키텍처
- `docs/standards/quality-checklist.md` - 품질 체크리스트

---

## 2. 분석 결과 상세

### 2.1 의도적 글로벌 (BelongsToTenant 불필요) - 18개

시스템 전역 데이터로, tenant_id 스코핑이 필요하지 않습니다.

| # | 모델 | 테이블 | tenant_id | 사유 |
|---|------|--------|:---------:|------|
| 1 | `ApiKey` | api_keys | ❌ | 시스템 API 키 |
| 2 | `ApiRequestLog` | api_request_logs | ❌ | 시스템 감시 로그 |
| 3 | `AuditLog` | audit_logs | ✅ | 전체 감사 로그 (자체 tenant_id 필터링) |
| 4 | `FcmSendLog` | fcm_send_logs | ✅ | 푸시 발송 로그 (수동 필터링) |
| 5 | `LoginToken` | login_tokens | ❌ | MNG→API 인증 토큰 |
| 6 | `SiteAdmin` | site_admins | ❌ | 시스템 관리자 |
| 7 | `Tenant` | tenants | ❌ | 테넌트 마스터 (자기 자신) |
| 8 | `Plan` | plans | ❌ | 구독 플랜 정의 |
| 9 | `Subscription` | subscriptions | ✅ | 구독 (tenant_id로 연결) |
| 10 | `Payment` | payments | ✅ | 결제 (tenant_id로 연결) |
| 11 | `User` | users | ❌ | 글로벌 사용자 계정 |
| 12 | `UserTenant` | user_tenants | ✅ | 사용자-테넌트 매핑 (피벗) |
| 13 | `UserRole` | user_roles | ✅ | 사용자-역할 매핑 (피벗) |
| 14 | `GlobalMenu` | global_menus | ❌ | 시스템 전역 메뉴 |
| 15 | `Tag` | tags | ❌ | 시스템 공용 태그 |
| 16 | `SystemFieldDefinition` | system_field_definitions | ❌ | 시스템 필드 정의 |
| 17 | `KdPriceTable` | kd_price_tables | ❌ | 경동 전용 단가표 (레거시) |
| 18 | `TenantScope` | - | - | 스코프 정의 클래스 (모델 아님) |

### 2.2 부모 종속 (FK 격리 - BelongsToTenant 면제) - 13개

부모 모델에 BelongsToTenant가 적용되어 있고, FK를 통해 자동 격리되는 자식 모델입니다.

| # | 모델 | 테이블 | 부모 모델 | FK |
|---|------|--------|----------|-----|
| 1 | `PostCustomFieldValue` | post_custom_field_values | Post | post_id |
| 2 | `DocumentData` | document_data | Document | document_id |
| 3 | `DocumentApproval` | document_approvals | Document | document_id |
| 4 | `DocumentAttachment` | document_attachments | Document | document_id |
| 5 | `RoleMenuPermission` | role_menu_permissions | Role | role_id |
| 6 | `UserMenuPermission` | user_menu_permissions | - | user_id + menu_id |
| 7 | `NotificationSettingGroupItem` | notification_setting_group_items | NotificationSettingGroup | group_id |
| 8 | `BillInstallment` | bill_installments | Bill | bill_id |
| 9 | `ApprovalStep` | approval_steps | Approval | approval_id |
| 10 | `OrderItemComponent` | order_item_components | OrderItem | order_item_id |
| 11 | `MaterialInspectionItem` | inspection_items | MaterialInspection | inspection_id |
| 12 | `QuoteFormulaItem` | quote_formula_items | QuoteFormula | formula_id |
| 13 | `QuoteFormulaRange` | quote_formula_ranges | QuoteFormula | formula_id |

**주의:** 이 모델들은 직접 쿼리할 때 부모를 통한 접근이 필수입니다. 직접 `::all()` 등으로 접근하면 테넌트 격리가 안됩니다.

### 2.3 🔴 BelongsToTenant 추가 필요 - 27개

tenant_id 컬럼이 있지만 BelongsToTenant trait가 없는 모델입니다. 추가해야 합니다.

| # | 모델 | 테이블 | 영역 | 우선순위 |
|---|------|--------|------|:--------:|
| **Design 영역 (4개)** | | | | |
| 1 | `DesignModel` | models | 설계 | 높음 |
| 2 | `ModelVersion` | model_versions | 설계 | 높음 |
| 3 | `BomTemplate` | bom_templates | 설계 | 높음 |
| 4 | `BomTemplateItem` | bom_template_items | 설계 | 높음 |
| **Orders 영역 (2개)** | | | | |
| 5 | `OrderHistory` | order_histories | 수주 | 높음 |
| 6 | `OrderVersion` | order_versions | 수주 | 높음 |
| **Materials 영역 (3개)** | | | | |
| 7 | `MaterialReceipt` | material_receipts | 자재 | 높음 |
| 8 | `MaterialInspection` | inspections | 자재 | 높음 |
| 9 | `MaterialInspectionItem` | inspection_items | 자재 | 중간 |
| **Tenants 설정 영역 (7개)** | | | | |
| 10 | `TenantOptionGroup` | tenant_option_groups | 설정 | 높음 |
| 11 | `TenantOptionValue` | tenant_option_values | 설정 | 높음 |
| 12 | `TenantFieldSetting` | tenant_field_settings | 설정 | 높음 |
| 13 | `TenantUserProfile` | tenant_user_profiles | 설정 | 중간 |
| 14 | `SettingFieldDef` | setting_field_defs | 설정 | 중간 |
| 15 | `TenantStatField` | tenant_stat_fields | 설정 | 중간 |
| 16 | `BarobillSetting` | barobill_settings | 설정 | 낮음 |
| **BadDebts 영역 (2개)** | | | | |
| 17 | `BadDebtDocument` | bad_debt_documents | 미수금 | 중간 |
| 18 | `BadDebtMemo` | bad_debt_memos | 미수금 | 중간 |
| **Permissions 영역 (2개)** | | | | |
| 19 | `Permission` | permissions | 권한 | 높음 |
| 20 | `PermissionOverride` | permission_overrides | 권한 | 높음 |
| **기타 영역 (8개)** | | | | |
| 21 | `MainRequest` | main_requests | 요청 | 높음 |
| 22 | `MainRequestFlow` | main_request_flows | 요청 | 높음 |
| 23 | `MainRequestEstimate` | main_request_estimates | 견적 | 높음 |
| 24 | `Schedule` | schedules | 일정 | 중간 |
| 25 | `ProcessItem` | process_items | 공정 | 중간 |
| 26 | `ProcessClassificationRule` | process_classification_rules | 공정 | 중간 |
| 27 | `ItemDetail` | item_details | 품목 | 중간 |

### 2.4 ⚠️ 검토 후 결정 필요 - 5개

커스텀 스코핑을 사용하거나, 특수한 비즈니스 로직이 있는 모델입니다.

| # | 모델 | 현재 방식 | 검토 사항 |
|---|------|----------|----------|
| 1 | `Board` | `scopeAccessible()` 커스텀 | tenant_id nullable; 시스템 게시판(tenant_id=null)과 테넌트 게시판 혼용 |
| 2 | `Post` | 수동 where 조건 | Board의 tenant_id 정책을 따름 |
| 3 | `BoardComment` | 수동 where 조건 | Post 종속 |
| 4 | `BoardSetting` | 수동 where 조건 | Board 종속 |
| 5 | `CompanyRequest` | `created_tenant_id` | tenant_id 대신 created_tenant_id 사용 |

**Board 영역 결론:** 시스템 게시판(tenant_id=null)을 지원해야 하므로, BelongsToTenant의 글로벌 스코프 대신 현재 커스텀 스코프(`scopeAccessible`) 유지가 적합합니다.

### 2.5 특수 케이스

| 모델 | 설명 |
|------|------|
| `Part` | tenant_id 있지만 BelongsToTenant 미적용. Product와 유사 역할이나 별도 테이블 |
| `Lot` / `LotSale` | tenant_id 있지만 미적용. 품질관리 영역 |
| `CalculationConfig` | tenant_id 있지만 미적용. 계산 설정 |
| `QuoteFormulaMapping` | tenant_id 있지만 미적용. 견적 수식 매핑 |

---

## 3. 작업 절차

### 3.1 단계별 절차

```
Phase 1: 고우선순위 모델 적용 (15개)
├── Design 영역: DesignModel, ModelVersion, BomTemplate, BomTemplateItem
├── Orders 영역: OrderHistory, OrderVersion
├── Materials 영역: MaterialReceipt, MaterialInspection
├── Permissions 영역: Permission, PermissionOverride
├── MainRequest 영역: MainRequest, MainRequestFlow, MainRequestEstimate
├── Tenants 설정: TenantOptionGroup, TenantOptionValue, TenantFieldSetting
└── 검증: 기존 서비스 로직에 중복 where 조건 확인 및 정리

Phase 2: 중간 우선순위 모델 적용 (10개)
├── Tenants 설정: TenantUserProfile, SettingFieldDef, TenantStatField
├── BadDebts: BadDebtDocument, BadDebtMemo
├── 기타: Schedule, ProcessItem, ProcessClassificationRule, ItemDetail
├── Materials: MaterialInspectionItem
└── 검증: 서비스 로직 정리

Phase 3: 특수 케이스 처리 (4개)
├── Part, Lot, LotSale, CalculationConfig, QuoteFormulaMapping
└── 각 모델별 비즈니스 로직 확인 후 적용

Phase 4: 서비스 레이어 정리
├── BelongsToTenant 적용 후 불필요한 수동 where('tenant_id', ...) 제거
├── 직접 쿼리하는 부모 종속 모델에 대한 접근 패턴 검토
└── 전체 통합 테스트
```

---

## 4. 상세 작업 내용

### 4.1 Phase 1: 고우선순위 (15개)

각 모델에 `use BelongsToTenant;` 추가. 작업 패턴:

```php
// Before
namespace App\Models\Design;

use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;

class DesignModel extends Model
{
    use ModelTrait;
    // ...
}

// After
namespace App\Models\Design;

use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;

class DesignModel extends Model
{
    use BelongsToTenant, ModelTrait;
    // ...
}
```

**Phase 1 대상 모델:**

| # | 파일 경로 | 상태 |
|---|----------|:----:|
| 1.1 | `app/Models/Design/DesignModel.php` | ⏳ |
| 1.2 | `app/Models/Design/ModelVersion.php` | ⏳ |
| 1.3 | `app/Models/Design/BomTemplate.php` | ⏳ |
| 1.4 | `app/Models/Design/BomTemplateItem.php` | ⏳ |
| 1.5 | `app/Models/Orders/OrderHistory.php` | ⏳ |
| 1.6 | `app/Models/Orders/OrderVersion.php` | ⏳ |
| 1.7 | `app/Models/Materials/MaterialReceipt.php` | ⏳ |
| 1.8 | `app/Models/Materials/MaterialInspection.php` | ⏳ |
| 1.9 | `app/Models/Permissions/Permission.php` | ⏳ |
| 1.10 | `app/Models/Permissions/PermissionOverride.php` | ⏳ |
| 1.11 | `app/Models/MainRequest.php` | ⏳ |
| 1.12 | `app/Models/MainRequestFlow.php` | ⏳ |
| 1.13 | `app/Models/Estimates/MainRequestEstimate.php` | ⏳ |
| 1.14 | `app/Models/Tenants/TenantOptionGroup.php` | ⏳ |
| 1.15 | `app/Models/Tenants/TenantOptionValue.php` | ⏳ |
| 1.16 | `app/Models/Tenants/TenantFieldSetting.php` | ⏳ |

### 4.2 Phase 2: 중간 우선순위 (10개)

| # | 파일 경로 | 상태 |
|---|----------|:----:|
| 2.1 | `app/Models/Tenants/TenantUserProfile.php` | ⏳ |
| 2.2 | `app/Models/Tenants/SettingFieldDef.php` | ⏳ |
| 2.3 | `app/Models/Tenants/TenantStatField.php` | ⏳ |
| 2.4 | `app/Models/BadDebts/BadDebtDocument.php` | ⏳ |
| 2.5 | `app/Models/BadDebts/BadDebtMemo.php` | ⏳ |
| 2.6 | `app/Models/Tenants/Schedule.php` | ⏳ |
| 2.7 | `app/Models/ProcessItem.php` | ⏳ |
| 2.8 | `app/Models/ProcessClassificationRule.php` | ⏳ |
| 2.9 | `app/Models/Items/ItemDetail.php` | ⏳ |
| 2.10 | `app/Models/Materials/MaterialInspectionItem.php` | ⏳ |

### 4.3 Phase 3: 특수 케이스 (5개)

| # | 파일 경로 | 검토 사항 | 상태 |
|---|----------|----------|:----:|
| 3.1 | `app/Models/Products/Part.php` | Product와 역할 중복 여부 | ⏳ |
| 3.2 | `app/Models/Qualitys/Lot.php` | 품질관리 독립 쿼리 패턴 | ⏳ |
| 3.3 | `app/Models/Qualitys/LotSale.php` | Lot 종속 여부 | ⏳ |
| 3.4 | `app/Models/Calculation/CalculationConfig.php` | 테넌트별 설정 확인 | ⏳ |
| 3.5 | `app/Models/Quote/QuoteFormulaMapping.php` | 공용 vs 테넌트별 | ⏳ |

### 4.4 Phase 4: 서비스 레이어 정리

BelongsToTenant 적용 후, 서비스에서 수동으로 `->where('tenant_id', $tenantId)` 하던 코드를 정리합니다.

**확인 대상 서비스:**
- `app/Services/Design/` - DesignModelService, BomTemplateService 등
- `app/Services/OrderService.php` - OrderHistory, OrderVersion 관련
- `app/Services/Materials/` - MaterialReceiptService 등
- `app/Services/MainRequestService.php`
- 기타 관련 서비스

**정리 패턴:**
```php
// Before (수동 스코핑)
$models = DesignModel::where('tenant_id', $this->tenantId())->get();

// After (BelongsToTenant 자동 스코핑)
$models = DesignModel::all(); // 글로벌 스코프가 자동 적용
```

---

## 5. 컨펌 대기 목록

| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | Boards 영역 | 현재 커스텀 스코프 유지 vs BelongsToTenant 전환 | Board, Post, BoardComment, BoardSetting | ⚠️ 확인 필요 |
| 2 | Permission 영역 | Spatie Permission 패키지와의 호환성 | Permission, PermissionOverride | ⚠️ 확인 필요 |
| 3 | Schedule 모델 | tenant_id nullable - 글로벌 일정 지원 유지 여부 | Schedule | ⚠️ 확인 필요 |
| 4 | CompanyRequest | created_tenant_id → tenant_id 통일 여부 | CompanyRequest | ⚠️ 확인 필요 |

---

## 6. 변경 이력

| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-01-29 | 분석 | 전체 모델 tenant_id 준수 분석 완료 | 167개 모델 분석 | - |
| 2026-01-29 | 문서 | 계획 문서 초안 작성 | docs/dev_plans/tenant-id-compliance-plan.md | - |

---

## 7. 참고 문서

- **DB 스키마**: `docs/specs/database-schema.md`
- **시스템 아키텍처**: `docs/architecture/system-overview.md`
- **API 규칙**: `docs/standards/api-rules.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **BelongsToTenant trait**: `api/app/Traits/BelongsToTenant.php`
- **TenantScope**: `api/app/Models/Scopes/TenantScope.php`

---

## 8. 리스크 및 주의사항

### 8.1 BelongsToTenant 적용 시 부작용

| 리스크 | 설명 | 대응 |
|--------|------|------|
| **중복 스코핑** | 서비스에서 이미 `where('tenant_id', ...)` 하고 있으면 중복 | Phase 4에서 수동 where 제거 |
| **withoutGlobalScope 필요** | 관리자 기능에서 전체 데이터 조회 시 | `withoutGlobalScopes()` 명시 |
| **테스트 실패** | tenant_id 컨텍스트 없는 테스트 | 테스트에서 tenant context 설정 |
| **마이그레이션 영향** | seeder/migration에서 직접 insert | tenant context 없이 실행 가능한지 확인 |

### 8.2 Permission 모델 특수 사항

Permission은 Spatie Permission 패키지를 확장하므로, BelongsToTenant 적용 시 패키지 내부 쿼리와 충돌 가능성이 있습니다. 적용 전 테스트가 필수입니다.

### 8.3 Schedule 모델 특수 사항

Schedule의 tenant_id는 nullable입니다. BelongsToTenant 적용 시 tenant_id=null인 글로벌 일정이 조회되지 않을 수 있습니다. TenantScope에서 nullable 처리가 필요할 수 있습니다.

---

## 9. 검증 결과

> 작업 완료 후 이 섹션에 검증 결과 추가

### 9.1 성공 기준

| 기준 | 측정 방법 | 달성 |
|------|----------|:----:|
| Phase 1 모델 전체 BelongsToTenant 적용 | 코드 확인 | ⏳ |
| Phase 2 모델 전체 BelongsToTenant 적용 | 코드 확인 | ⏳ |
| 서비스 레이어 중복 where 제거 | grep 검색 | ⏳ |
| 기존 API 기능 정상 동작 | Swagger 테스트 | ⏳ |
| Pint 포맷팅 통과 | `./vendor/bin/pint --test` | ⏳ |

---

## 10. 자기완결성 점검 결과

### 10.1 체크리스트 검증

| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | tenant_id 미적용 모델에 BelongsToTenant 추가 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 9.1 참조 |
| 3 | 작업 범위가 구체적인가? | ✅ | 27개 모델 명시, 5개 검토 대상 분리 |
| 4 | 의존성이 명시되어 있는가? | ✅ | Spatie Permission, Boards 커스텀 스코프 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 전체 모델 파일 경로 명시 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | Phase 1~4 구체적 작업 항목 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 성공 기준 및 테스트 방법 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 모델명, 파일 경로, 수치 사용 |

### 10.2 새 세션 시뮬레이션 테스트

| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 4.1 Phase 1 |
| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 2.3 수정 필요 목록 + 4.1~4.3 파일 경로 |
| Q4. 작업 완료 확인 방법은? | ✅ | 9.1 성공 기준 |
| Q5. 막혔을 때 참고 문서는? | ✅ | 7. 참고 문서 |

**결과**: 5/5 통과 → ✅ 자기완결성 확보

---

*이 문서는 /sc:plan 스킬로 생성되었습니다.*