# MNG 메뉴 시스템: DB 기반 동적 메뉴 전환 계획 > 작성일: 2025-12-16 > 수정일: 2025-12-16 (Laravel 12 미들웨어, JSON options 컬럼 방식으로 변경) > 목적: mng 사이드바를 DB 기반으로 전환하여 직원별 권한에 따라 메뉴 동적 표시 > 선택: **Option A - DB 메뉴 기반** --- ## 1. 현재 시스템 분석 ### 1.1 현재 구조 (AS-IS) ``` ┌─────────────────────────────────────────────────────────────────┐ │ 현재 mng 메뉴 시스템 │ ├─────────────────────────────────────────────────────────────────┤ │ sidebar.blade.php (하드코딩) │ │ ├── 일반 메뉴 (7개 그룹) │ │ ├── 개발도구 메뉴 │ │ └── R&D Labs 메뉴 │ ├─────────────────────────────────────────────────────────────────┤ │ 권한 체크: hq.member 미들웨어만 (HQ 소속 확인) │ │ 메뉴별 권한 체크: 없음 (전체 접근) │ └─────────────────────────────────────────────────────────────────┘ ``` ### 1.2 목표 구조 (TO-BE) ``` ┌─────────────────────────────────────────────────────────────────┐ │ 목표 mng 메뉴 시스템 │ ├─────────────────────────────────────────────────────────────────┤ │ DB (menus 테이블 + options JSON 컬럼, tenant_id=1) │ │ ├── 일반 메뉴 (역할/부서/개인 권한으로 제어) │ │ ├── 개발도구 메뉴 (슈퍼관리자 전용) │ │ └── R&D Labs 메뉴 (슈퍼관리자 전용) │ ├─────────────────────────────────────────────────────────────────┤ │ 동적 사이드바 렌더링: SidebarMenuService → Blade Component │ │ 권한 체크: 메뉴별 permission (menu:{id}.view) │ └─────────────────────────────────────────────────────────────────┘ ``` ### 1.3 DB 설계 방식 비교 | 방식 | 장점 | 단점 | |------|------|------| | **별도 테이블** | menus 완전 무수정 | JOIN 필요, 테이블 관리 | | **JSON 컬럼** ✅ | JOIN 불필요, 유연한 확장, 범용 | menus 수정 (안전) | **선택: JSON 컬럼 방식** - nullable JSON 컬럼 추가는 기존 코드에 영향 없음 - Laravel의 JSON 캐스팅으로 편리한 사용 - 나중에 API, React에서도 활용 가능 ### 1.4 DB 테이블 구조 **menus 테이블** (기존 + options 컬럼 추가) ``` id, tenant_id, parent_id, global_menu_id name, url, icon, sort_order is_active, hidden, is_customized, is_external, external_url options (JSON, nullable) ← 신규 추가 created_by, updated_by, deleted_by, created_at, updated_at, deleted_at ``` **options JSON 구조** (범용 설계) ```json { "route_name": "dashboard", "section": "main", "menu_type": "normal", "requires_role": null, "blade_component": null, "css_class": null, "meta": {} } ``` | 필드 | 타입 | 설명 | 예시 | |------|------|------|------| | `route_name` | string | Laravel 라우트 이름 | `"pm.projects.index"` | | `section` | string | 메뉴 섹션 위치 | `"main"`, `"tools"`, `"labs"` | | `menu_type` | string | 메뉴 유형 | `"normal"`, `"tool"`, `"lab"` | | `requires_role` | string | 필요 역할 | `"super_admin"`, `null` | | `blade_component` | string | 커스텀 컴포넌트 | `"menus.custom-item"` | | `css_class` | string | 추가 CSS 클래스 | `"text-red-500"` | | `meta` | object | 앱별 추가 데이터 | `{"tab": "s"}` | > **범용 설계 원칙**: mng 고유 필드는 `meta`에 저장, 공통 필드만 최상위에 배치 **permissions 테이블** (Spatie) ``` id, tenant_id, name, guard_name, created_at, updated_at ``` **권한 연결 테이블** - `role_has_permissions`: 역할-권한 매핑 - `department_permissions`: 부서-권한 매핑 (is_allowed) - `user_permission_overrides`: 개인-권한 오버라이드 (is_allowed) --- ## 2. 권한 체계 설계 ### 2.1 권한 우선순위 ``` 개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할 권한 > 기본 거부 ``` ### 2.2 메뉴 권한 명명 규칙 ``` menu:{menu_id}.view # 메뉴 조회 (사이드바 표시) menu:{menu_id}.create # 생성 권한 menu:{menu_id}.update # 수정 권한 menu:{menu_id}.delete # 삭제 권한 ``` ### 2.3 특수 메뉴 처리 | 메뉴 유형 | 권한 처리 | |-----------|----------| | 일반 메뉴 | 역할/부서/개인 권한으로 제어 | | 개발도구 | `options.requires_role = "super_admin"` | | R&D Labs | `options.requires_role = "super_admin"` | --- ## 3. 개발 계획 ### Phase 1: DB 스키마 및 시딩 (1-2일) #### 3.1.1 options 컬럼 마이그레이션 ```php // database/migrations/xxxx_add_options_to_menus_table.php json('options')->nullable() ->after('external_url') ->comment('확장 옵션 (JSON): route_name, section, menu_type, requires_role, meta 등'); }); } public function down(): void { Schema::table('menus', function (Blueprint $table) { $table->dropColumn('options'); }); } }; ``` #### 3.1.2 Menu 모델 수정 (API) ```php // api/app/Models/Commons/Menu.php 'boolean', 'hidden' => 'boolean', 'is_customized' => 'boolean', 'is_external' => 'boolean', 'options' => 'array', // 추가 ]; // 헬퍼 메서드 (선택적) public function getOption(string $key, mixed $default = null): mixed { return data_get($this->options, $key, $default); } public function getRouteName(): ?string { return $this->getOption('route_name'); } public function getSection(): string { return $this->getOption('section', 'main'); } public function getMenuType(): string { return $this->getOption('menu_type', 'normal'); } public function requiresRole(): ?string { return $this->getOption('requires_role'); } } ``` #### 3.1.3 mng 메뉴 시더 생성 ```php // mng/database/seeders/MngMenuSeeder.php '대시보드', 'url' => '/dashboard', 'icon' => 'home', 'options' => [ 'route_name' => 'dashboard', 'section' => 'main', 'menu_type' => 'normal', ], ], // 그룹: 프로젝트 관리 [ 'name' => '프로젝트 관리', 'url' => null, 'icon' => 'folder', 'options' => [ 'section' => 'main', 'menu_type' => 'normal', ], 'children' => [ [ 'name' => '프로젝트 대시보드', 'url' => '/project-management', 'options' => ['route_name' => 'pm.index'], ], [ 'name' => '프로젝트', 'url' => '/project-management/projects', 'options' => ['route_name' => 'pm.projects.index'], ], [ 'name' => '일일 스크럼', 'url' => '/daily-logs', 'options' => ['route_name' => 'daily-logs.index'], ], ], ], // ... 기타 메뉴 그룹 // 개발도구 (슈퍼관리자 전용) [ 'name' => '개발 도구', 'url' => null, 'icon' => 'cog', 'options' => [ 'section' => 'tools', 'menu_type' => 'tool', 'requires_role' => 'super_admin', ], 'children' => [ [ 'name' => 'API 플로우 테스터', 'url' => '/dev-tools/flow-tester', 'options' => [ 'route_name' => 'dev-tools.flow-tester.index', 'requires_role' => 'super_admin', ], ], [ 'name' => 'API 요청 로그', 'url' => '/dev-tools/api-logs', 'options' => [ 'route_name' => 'dev-tools.api-logs.index', 'requires_role' => 'super_admin', ], ], ], ], // R&D Labs (슈퍼관리자 전용) [ 'name' => 'R&D Labs', 'url' => null, 'icon' => 'beaker', 'options' => [ 'section' => 'labs', 'menu_type' => 'lab', 'requires_role' => 'super_admin', ], 'children' => [ // 하위 메뉴들 (meta에 앱별 데이터 저장 가능) [ 'name' => 'S Lab', 'url' => '/labs/s', 'options' => [ 'route_name' => 'labs.s.index', 'requires_role' => 'super_admin', 'meta' => ['tab' => 's'], ], ], ], ], ]; $this->seedMenus($tenantId, $menus); } private function seedMenus(int $tenantId, array $menus, ?int $parentId = null): void { foreach ($menus as $index => $menuData) { $children = $menuData['children'] ?? []; unset($menuData['children']); // 메뉴 생성 $menu = Menu::create([ 'tenant_id' => $tenantId, 'parent_id' => $parentId, 'name' => $menuData['name'], 'url' => $menuData['url'], 'icon' => $menuData['icon'] ?? null, 'sort_order' => $index, 'is_active' => true, 'options' => $menuData['options'] ?? null, ]); // 자식 메뉴 재귀 처리 if (!empty($children)) { $this->seedMenus($tenantId, $children, $menu->id); } } } } ``` ### Phase 2: 사용자별 메뉴 조회 서비스 (2-3일) #### 3.2.1 SidebarMenuService 생성 ```php // mng/app/Services/SidebarMenuService.php user(); $tenantId = session('selected_tenant_id', 1); // 1. 테넌트의 모든 활성 메뉴 조회 $allMenus = Menu::where('tenant_id', $tenantId) ->where('is_active', true) ->where('hidden', false) ->orderBy('sort_order') ->get(); // 2. 슈퍼관리자는 모든 메뉴 표시 if ($user->is_super_admin) { return $this->buildMenuTree($allMenus); } // 3. 일반 사용자: 권한 기반 필터링 $permittedMenuIds = $this->getPermittedMenuIds($user, $tenantId); // 4. 역할 필요 메뉴 및 특수 메뉴 제외 $filteredMenus = $allMenus->filter(function ($menu) use ($permittedMenuIds, $user) { // requires_role 체크 $requiredRole = $menu->getOption('requires_role'); if ($requiredRole && !$this->hasRole($user, $requiredRole)) { return false; } // 권한 체크 return in_array($menu->id, $permittedMenuIds); }); return $this->buildMenuTree($filteredMenus); } /** * 섹션별 메뉴 조회 (main, tools, labs) */ public function getMenusBySection(?User $user = null): array { $menuTree = $this->getUserMenuTree($user); return [ 'main' => $menuTree->filter(fn($m) => $m->getSection() === 'main')->values(), 'tools' => $menuTree->filter(fn($m) => $m->getSection() === 'tools')->values(), 'labs' => $menuTree->filter(fn($m) => $m->getSection() === 'labs')->values(), ]; } /** * 역할 확인 */ private function hasRole(User $user, string $role): bool { return match ($role) { 'super_admin' => $user->is_super_admin, default => $user->hasRole($role), }; } /** * 사용자가 접근 가능한 메뉴 ID 목록 조회 * 우선순위: 개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할 */ private function getPermittedMenuIds(User $user, int $tenantId): array { // 역할 기반 권한 $rolePermissions = $this->getRoleMenuPermissions($user, $tenantId); // 부서 기반 권한 (ALLOW/DENY) $deptPermissions = $this->getDepartmentMenuPermissions($user, $tenantId); // 개인 오버라이드 (ALLOW/DENY) $userOverrides = $this->getUserMenuOverrides($user, $tenantId); // 권한 병합 (우선순위 적용) return $this->mergePermissions($rolePermissions, $deptPermissions, $userOverrides); } private function getRoleMenuPermissions(User $user, int $tenantId): array { // menu:*.view 형식의 권한에서 메뉴 ID 추출 return $user->getPermissionsViaRoles() ->filter(fn($p) => str_starts_with($p->name, 'menu:') && str_ends_with($p->name, '.view')) ->pluck('name') ->map(fn($name) => (int) explode('.', explode(':', $name)[1])[0]) ->toArray(); } private function getDepartmentMenuPermissions(User $user, int $tenantId): array { if (!$user->department_id) { return []; } return DB::table('department_permissions') ->where('department_id', $user->department_id) ->where('permission_id', 'LIKE', 'menu:%') ->get() ->mapWithKeys(fn($row) => [ (int) explode('.', explode(':', $row->permission_id)[1])[0] => $row->is_allowed ]) ->toArray(); } private function getUserMenuOverrides(User $user, int $tenantId): array { return DB::table('user_permission_overrides') ->where('user_id', $user->id) ->where('permission_id', 'LIKE', 'menu:%') ->get() ->mapWithKeys(fn($row) => [ (int) explode('.', explode(':', $row->permission_id)[1])[0] => $row->is_allowed ]) ->toArray(); } private function mergePermissions(array $role, array $dept, array $user): array { $allMenuIds = array_unique(array_merge( $role, array_keys($dept), array_keys($user) )); $permitted = []; foreach ($allMenuIds as $menuId) { // 우선순위: 개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할 if (isset($user[$menuId])) { if ($user[$menuId]) { $permitted[] = $menuId; } continue; } if (isset($dept[$menuId])) { if ($dept[$menuId]) { $permitted[] = $menuId; } continue; } if (in_array($menuId, $role)) { $permitted[] = $menuId; } } return $permitted; } private function buildMenuTree(Collection $menus, ?int $parentId = null): Collection { return $menus->where('parent_id', $parentId) ->map(function ($menu) use ($menus) { $menu->children = $this->buildMenuTree($menus, $menu->id); return $menu; }); } } ``` ### Phase 3: 동적 사이드바 컴포넌트 (2-3일) #### 3.3.1 Blade 컴포넌트 구조 ``` resources/views/ ├── components/ │ └── sidebar/ │ ├── menu-tree.blade.php # 메뉴 트리 전체 │ ├── menu-group.blade.php # 그룹 (접기/펼치기) │ ├── menu-item.blade.php # 개별 메뉴 아이템 │ └── menu-icon.blade.php # 아이콘 렌더링 └── partials/ └── sidebar-dynamic.blade.php # 동적 사이드바 (기존 대체) ``` #### 3.3.2 메뉴 트리 컴포넌트 ```blade {{-- components/sidebar/menu-tree.blade.php --}} @props(['menus'])