# 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']) ``` #### 3.3.3 ViewServiceProvider에서 메뉴 공유 ```php // mng/app/Providers/ViewServiceProvider.php getMenusBySection(); $view->with([ 'mainMenus' => $menusBySection['main'], 'toolsMenus' => $menusBySection['tools'], 'labsMenus' => $menusBySection['labs'], ]); }); } } ``` ### Phase 4: 라우트 권한 미들웨어 (1-2일) #### 3.4.1 메뉴 권한 체크 미들웨어 ```php // mng/app/Http/Middleware/CheckMenuPermission.php user(); // 슈퍼관리자는 패스 if ($user->is_super_admin) { return $next($request); } // 라우트 이름으로 메뉴 찾기 (options JSON에서) $routeName = $request->route()->getName(); $menu = Menu::where('tenant_id', session('selected_tenant_id', 1)) ->whereJsonContains('options->route_name', $routeName) ->first(); if (!$menu) { return $next($request); // 메뉴 등록 안 된 라우트는 패스 } // requires_role 체크 $requiredRole = $menu->getOption('requires_role'); if ($requiredRole) { if ($requiredRole === 'super_admin' && !$user->is_super_admin) { abort(403, '슈퍼관리자만 접근 가능합니다.'); } if ($requiredRole !== 'super_admin' && !$user->hasRole($requiredRole)) { abort(403, '접근 권한이 없습니다.'); } } $permissionName = $permission ?? "menu:{$menu->id}.view"; if (!$user->can($permissionName)) { abort(403, '접근 권한이 없습니다.'); } return $next($request); } } ``` #### 3.4.2 미들웨어 등록 (Laravel 12 방식) ```php // mng/bootstrap/app.php withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'hq.member' => \App\Http\Middleware\EnsureHQMember::class, 'super.admin' => \App\Http\Middleware\EnsureSuperAdmin::class, 'password.changed' => \App\Http\Middleware\EnsurePasswordChanged::class, // 신규 메뉴 권한 미들웨어 추가 'menu.permission' => \App\Http\Middleware\CheckMenuPermission::class, ]); }) ->withExceptions(function ($exceptions) { // }) ->create(); ``` #### 3.4.3 라우트에 미들웨어 적용 ```php // routes/web.php Route::middleware(['auth', 'hq.member', 'menu.permission'])->group(function () { // 기존 라우트들... }); ``` --- ## 4. 마이그레이션 전략 ### 4.1 단계별 전환 ``` Phase 1: 준비 (하드코딩 + DB 병행) ├── menus 테이블에 options 컬럼 추가 ├── mng 메뉴 시딩 ├── SidebarMenuService 개발 └── 기존 sidebar.blade.php 유지 Phase 2: 테스트 (환경변수로 전환) ├── .env에 MNG_DYNAMIC_SIDEBAR=false ├── 동적 사이드바 개발 완료 └── 슈퍼관리자만 동적 사이드바 테스트 Phase 3: 전환 (동적 사이드바 활성화) ├── MNG_DYNAMIC_SIDEBAR=true ├── 권한 시딩 및 역할 배정 └── 기존 sidebar.blade.php 백업 Phase 4: 안정화 ├── 하드코딩 사이드바 제거 ├── 권한 관리 UI 활성화 └── 문서화 완료 ``` ### 4.2 롤백 계획 ```php // partials/sidebar.blade.php @if(config('app.mng_dynamic_sidebar', false)) @include('partials.sidebar-dynamic') @else @include('partials.sidebar-static') // 기존 하드코딩 @endif ``` --- ## 5. 파일 변경 목록 ### 신규 생성 | 파일 | 설명 | |------|------| | `api/database/migrations/xxxx_add_options_to_menus.php` | options 컬럼 추가 | | `mng/database/seeders/MngMenuSeeder.php` | mng 메뉴 시더 | | `mng/database/seeders/MngMenuPermissionSeeder.php` | mng 메뉴 권한 시더 | | `mng/app/Services/SidebarMenuService.php` | 사용자별 메뉴 조회 | | `mng/app/Http/Middleware/CheckMenuPermission.php` | 메뉴 권한 미들웨어 | | `mng/app/Providers/ViewServiceProvider.php` | 뷰 컴포저 | | `mng/resources/views/partials/sidebar-dynamic.blade.php` | 동적 사이드바 | | `mng/resources/views/components/sidebar/*.blade.php` | 사이드바 컴포넌트들 | ### 수정 | 파일 | 변경 내용 | |------|----------| | `api/app/Models/Commons/Menu.php` | options 캐스팅 + 헬퍼 메서드 | | `mng/bootstrap/app.php` | CheckMenuPermission 미들웨어 등록 | | `mng/config/app.php` | mng_dynamic_sidebar 설정 추가 | | `mng/routes/web.php` | 미들웨어 적용 | | `mng/resources/views/partials/sidebar.blade.php` | 조건부 렌더링 | --- ## 6. 예상 일정 | Phase | 작업 | 예상 기간 | |-------|------|----------| | Phase 1 | DB 스키마 및 시딩 | 1-2일 | | Phase 2 | SidebarMenuService | 2-3일 | | Phase 3 | 동적 사이드바 컴포넌트 | 2-3일 | | Phase 4 | 권한 미들웨어 | 1-2일 | | 테스트 | 통합 테스트 및 버그 수정 | 2-3일 | | **총합** | | **8-13일** | --- ## 7. 체크리스트 ### 개발 전 - [ ] 현재 mng 메뉴 목록 완전히 정리 (그룹/항목/라우트) - [ ] 권한 명명 규칙 확정 - [ ] 개발도구/Labs 접근 정책 확정 ### Phase 1 완료 조건 - [ ] 마이그레이션 실행 성공 (options 컬럼 추가) - [ ] mng 메뉴 시더 실행 성공 - [ ] DB에 모든 mng 메뉴 존재 확인 - [ ] 기존 API/React 영향 없음 확인 ### Phase 2 완료 조건 - [ ] SidebarMenuService 단위 테스트 통과 - [ ] 슈퍼관리자 전체 메뉴 조회 확인 - [ ] 일반 사용자 권한 기반 필터링 확인 ### Phase 3 완료 조건 - [ ] 동적 사이드바 렌더링 정상 - [ ] 메뉴 접기/펼치기 동작 - [ ] 활성 메뉴 하이라이트 동작 - [ ] 사이드바 collapse 상태 동작 ### Phase 4 완료 조건 - [ ] 미들웨어 권한 체크 동작 - [ ] 403 에러 페이지 표시 - [ ] 권한 없는 메뉴 URL 직접 접근 차단 ### 전환 완료 조건 - [ ] 모든 테스트 통과 - [ ] 기존 기능 동일 동작 확인 - [ ] 성능 영향 최소화 확인 (캐싱) - [ ] 롤백 가능 확인 --- ## 8. 추가 고려사항 ### 8.1 캐싱 전략 ```php // 사용자별 메뉴 캐싱 (권한 변경 시 무효화) Cache::remember("user:{$userId}:menus", 3600, function () use ($userId) { return $this->getUserMenuTree(User::find($userId)); }); ``` ### 8.2 권한 변경 시 캐시 무효화 ```php // 역할 권한 변경 시 Cache::tags(['menus'])->flush(); // 개인 권한 변경 시 Cache::forget("user:{$userId}:menus"); ``` ### 8.3 감사 로그 ```php // 메뉴 접근 로그 (선택적) AuditLog::create([ 'action' => 'menu_access', 'target_type' => 'menu', 'target_id' => $menu->id, 'actor_id' => auth()->id(), ]); ``` --- ## 9. 다음 단계 이 계획을 승인하시면 다음 순서로 진행합니다: 1. **현재 mng 메뉴 전체 목록 정리** (그룹/항목/라우트/아이콘) 2. **마이그레이션 작성** (menus.options 컬럼) 3. **Menu 모델 수정** (options 캐스팅) 4. **SidebarMenuService 개발** 5. **동적 사이드바 컴포넌트 개발** 6. **권한 미들웨어 적용** 7. **테스트 및 전환** 진행하시겠습니까?