# 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 스킬로 생성되었습니다.*